From 8fa78eb31e396b305f4afaf930b58e0de83d050c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 07:55:48 +0000 Subject: [PATCH 1/9] test: share the TAP helpers between the suites 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. --- debian/{fab.docs => keel-fab.docs} | 0 debian/{fab.install => keel-fab.install} | 0 debian/{fab.links => keel-fab.links} | 0 tests/source-date-epoch.sh | 16 ++-------- tests/tap.sh | 40 ++++++++++++++++++++++++ tests/units.sh | 25 ++------------- 6 files changed, 44 insertions(+), 37 deletions(-) rename debian/{fab.docs => keel-fab.docs} (100%) rename debian/{fab.install => keel-fab.install} (100%) rename debian/{fab.links => keel-fab.links} (100%) create mode 100644 tests/tap.sh diff --git a/debian/fab.docs b/debian/keel-fab.docs similarity index 100% rename from debian/fab.docs rename to debian/keel-fab.docs diff --git a/debian/fab.install b/debian/keel-fab.install similarity index 100% rename from debian/fab.install rename to debian/keel-fab.install diff --git a/debian/fab.links b/debian/keel-fab.links similarity index 100% rename from debian/fab.links rename to debian/keel-fab.links diff --git a/tests/source-date-epoch.sh b/tests/source-date-epoch.sh index d56a1b4..90187a0 100755 --- a/tests/source-date-epoch.sh +++ b/tests/source-date-epoch.sh @@ -59,20 +59,8 @@ dry_run() { # --- TAP helpers ------------------------------------------------------------ -count=0 -failed=0 - -ok() { - count=$((count + 1)) - echo "ok $count - $1" -} - -not_ok() { - count=$((count + 1)) - failed=$((failed + 1)) - echo "not ok $count - $1" - printf '%s\n' "$2" | sed 's/^/# /' -} +# shellcheck source=tests/tap.sh +. "$here/tap.sh" # check DESC TEXT REGEX: a line of TEXT matches REGEX check() { diff --git a/tests/tap.sh b/tests/tap.sh new file mode 100644 index 0000000..84664d8 --- /dev/null +++ b/tests/tap.sh @@ -0,0 +1,40 @@ +# shellcheck shell=bash +# TAP helpers shared by the suites under tests/. Source it, never run it: +# +# . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/tap.sh" +# +# It defines count, failed, ok, not_ok and is. A suite ends with +# +# echo "1..$count" +# exit "$failed" +# +# so the exit status is the number of failed checks, and tests/coverage.sh +# can count the "ok" and "not ok" lines of every suite the same way. +# +# This file exists because three suites wanted the same twenty lines. 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. + +count=0 +failed=0 + +ok() { + count=$((count + 1)) + echo "ok $count - $1" +} + +not_ok() { + count=$((count + 1)) + failed=$((failed + 1)) + echo "not ok $count - $1" + printf '%s\n' "$2" | sed 's/^/# /' +} + +is() { + if [[ "$2" == "$3" ]]; then + ok "$1" + else + not_ok "$1" "got: $2 +want: $3" + fi +} diff --git a/tests/units.sh b/tests/units.sh index 0b25bc9..50a0173 100755 --- a/tests/units.sh +++ b/tests/units.sh @@ -128,29 +128,8 @@ make_var() { # --- TAP helpers ------------------------------------------------------------ -count=0 -failed=0 - -ok() { - count=$((count + 1)) - echo "ok $count - $1" -} - -not_ok() { - count=$((count + 1)) - failed=$((failed + 1)) - echo "not ok $count - $1" - printf '%s\n' "$2" | sed 's/^/# /' -} - -is() { - if [[ "$2" == "$3" ]]; then - ok "$1" - else - not_ok "$1" "got: $2 -want: $3" - fi -} +# shellcheck source=tests/tap.sh +. "$here/tap.sh" # builds DESC TARGET [VAR=VALUE ...]: the recipe runs to the end builds() { From b13febe4fe7e7c9af3411f692f8a21b83521922b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 07:56:01 +0000 Subject: [PATCH 2/9] feat: check that every released version names the commit it was built 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 / 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. --- bin/check-release-tags | 113 +++++++++++++++++++++++++ tests/release-tags.sh | 187 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100755 bin/check-release-tags create mode 100755 tests/release-tags.sh diff --git a/bin/check-release-tags b/bin/check-release-tags new file mode 100755 index 0000000..a99c403 --- /dev/null +++ b/bin/check-release-tags @@ -0,0 +1,113 @@ +#!/bin/bash +# Every version this repository has released must be reachable from a git +# tag, in both directions. +# +# 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 names a commit. On 2026-09-27 a package +# was built at 17:14 from a commit that reached the default branch at 19:58 +# and was never tagged, so for three hours the machine that builds every +# layer ran a version no clone could resolve to a commit. +# +# The rule this enforces: +# +# - every entry of debian/changelog whose distribution is not UNRELEASED +# is a release; +# - the newest release is the one being proposed and may be untagged; +# - every release below it must have a tag /, and the +# tree that tag names must carry at the top of its changelog. +# +# The source name comes from each entry, so versions released before a +# rename are checked under the name they were released as: fab/1.1.1+keel2, +# not keel-fab/1.1.1+keel2. +# +# Going the other way needs no extra machinery: "git describe --match +# '*/*'" on a commit says which release it belongs to, and the tag's +# changelog says the version a manifest would record. +# +# bin/check-release-tags [REPOSITORY] (default: the current directory) +# +# Exit 0 every release is tagged, 1 a release is untagged or a tag does not +# carry its version, 2 the repository or its changelog cannot be read. + +set -uo pipefail + +PROGRAM="$(basename "$0")" + +die() { + local status=$1 + shift + printf '%s: %s\n' "$PROGRAM" "$*" >&2 + exit "$status" +} + +# changelog_entries FILE: " " per entry, +# newest first. +changelog_entries() { + sed -nE 's/^([a-z0-9][a-z0-9.+-]*) \(([^)]+)\) +([^ ;]+).*/\1 \2 \3/p' "$1" +} + +# tag_version REPOSITORY TAG: the version at the top of the changelog of the +# tree that TAG names, empty when there is none. +tag_version() { + git -C "$1" show "$2:debian/changelog" 2>/dev/null | + sed -nE '1s/^[a-z0-9][a-z0-9.+-]* \(([^)]+)\).*/\1/p' +} + +# has_tag REPOSITORY TAG +has_tag() { + git -C "$1" rev-parse -q --verify "refs/tags/$2" >/dev/null 2>&1 +} + +repository="${1:-.}" + +git -C "$repository" rev-parse --git-dir >/dev/null 2>&1 || + die 2 "$repository is not a git repository" + +changelog="$repository/debian/changelog" +[[ -f "$changelog" ]] || die 2 "$changelog does not exist" + +mapfile -t entries < <(changelog_entries "$changelog") +[[ ${#entries[@]} -gt 0 ]] || die 2 "$changelog has no entries" + +problems=0 +proposed="" + +for entry in "${entries[@]}"; do + read -r source version distribution <<<"$entry" + [[ "$distribution" == UNRELEASED ]] && continue + + tag="$source/$version" + + if [[ -z "$proposed" ]]; then + proposed="$tag" + if has_tag "$repository" "$tag"; then + echo "$tag is the newest release and is tagged" + else + echo "$tag is the release being proposed, not tagged yet" + fi + continue + fi + + if ! has_tag "$repository" "$tag"; then + printf '%s: %s is missing: %s was released and no tag names the commit it was built from\n' \ + "$PROGRAM" "$tag" "$version" >&2 + problems=$((problems + 1)) + continue + fi + + carried="$(tag_version "$repository" "$tag")" + if [[ "$carried" != "$version" ]]; then + printf '%s: %s names a tree whose changelog says %s, not %s\n' \ + "$PROGRAM" "$tag" "${carried:-nothing}" "$version" >&2 + problems=$((problems + 1)) + continue + fi + + echo "$tag names the commit that carries $version" +done + +[[ -n "$proposed" ]] || die 2 "$changelog has no released entry" + +[[ $problems -eq 0 ]] || + die 1 "$problems released version(s) cannot be traced to a tag" diff --git a/tests/release-tags.sh b/tests/release-tags.sh new file mode 100755 index 0000000..adf1272 --- /dev/null +++ b/tests/release-tags.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Test for bin/check-release-tags: the rule that every version this +# repository has released is reachable from a git tag. +# +# Why the rule exists. 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". On 2026-09-27 a package was built at 17:14 from +# a commit that reached the default branch at 19:58 and was never tagged, so +# for three hours the machine that builds every layer ran a version that no +# clone of this repository could name a commit for. A version string is only +# provenance if it maps to a commit, and a tag is what makes that map total. +# +# The script is driven here against git repositories built in a scratch +# directory, so the checks do not depend on the tags of this repository. +# Output is TAP; the exit status is the number of failed checks. +# +# tests/release-tags.sh + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/.." && pwd)" +script="$root/bin/check-release-tags" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# shellcheck source=tests/tap.sh +. "$here/tap.sh" + +# --- fixtures ----------------------------------------------------------------- + +# fixture NAME: a fresh repository at $work/NAME, printed +fixture() { + local dir="$work/$1" + mkdir -p "$dir/debian" + git -C "$dir" init -q -b main + git -C "$dir" config user.email tests@example.invalid + git -C "$dir" config user.name Tests + printf '%s\n' "$dir" +} + +# release DIR SOURCE VERSION [DISTRIBUTION]: prepend a changelog entry and +# commit it +release() { + local dir=$1 source=$2 version=$3 dist=${4:-trixie} old="" + [[ -f "$dir/debian/changelog" ]] && old="$(cat "$dir/debian/changelog")" + { + printf '%s (%s) %s; urgency=low\n\n * a change\n\n' \ + "$source" "$version" "$dist" + printf ' -- Tests Mon, 01 Jan 2029 00:00:00 +0000\n' + [[ -n "$old" ]] && printf '\n%s\n' "$old" + } > "$dir/debian/changelog" + git -C "$dir" add -A + git -C "$dir" commit -qm "$source $version" +} + +# tag DIR NAME: tag the current commit +tag() { + git -C "$1" tag "$2" +} + +# run DIR...: run the script, leaving stdout and stderr in $out and the +# exit status in $status +run() { + out="$("$script" "$@" 2>&1)" + status=$? +} + +# --- every released version is tagged ----------------------------------------- + +repo="$(fixture tagged)" +release "$repo" keel-fab 0.1.0 +tag "$repo" keel-fab/0.1.0 +release "$repo" keel-fab 0.2.0 +tag "$repo" keel-fab/0.2.0 +release "$repo" keel-fab 0.3.0 + +run "$repo" +is "a repository whose releases are all tagged passes" "$status" "0" +if grep -q 'keel-fab/0.1.0' <<<"$out" && grep -q 'keel-fab/0.2.0' <<<"$out"; then + ok "it names every tag it verified" +else + not_ok "it names every tag it verified" "$out" +fi +if grep -q '0.3.0' <<<"$out"; then + ok "it says the top entry is the one being proposed" +else + not_ok "it says the top entry is the one being proposed" "$out" +fi + +# --- a released version with no tag ------------------------------------------- + +repo="$(fixture untagged)" +release "$repo" keel-fab 0.1.0 +release "$repo" keel-fab 0.2.0 + +run "$repo" +is "a released version with no tag fails" "$status" "1" +if grep -q 'keel-fab/0.1.0' <<<"$out"; then + ok "the failure names the tag that is missing" +else + not_ok "the failure names the tag that is missing" "$out" +fi + +# --- a tag that does not carry the version it names --------------------------- + +repo="$(fixture mistagged)" +release "$repo" keel-fab 0.1.0 +release "$repo" keel-fab 0.2.0 +tag "$repo" keel-fab/0.2.0 +tag "$repo" keel-fab/0.1.0 # also on the 0.2.0 commit, which is the mistake +release "$repo" keel-fab 0.3.0 + +run "$repo" +is "a tag on the wrong commit fails" "$status" "1" +if grep -q '0.2.0' <<<"$out"; then + ok "the failure says which version the tag actually carries" +else + not_ok "the failure says which version the tag actually carries" "$out" +fi + +# --- entries that are not releases -------------------------------------------- + +repo="$(fixture unreleased)" +release "$repo" keel-fab 0.1.0 UNRELEASED +release "$repo" keel-fab 0.2.0 +release "$repo" keel-fab 0.3.0 + +run "$repo" +is "a released entry below the top is still required to be tagged" \ + "$status" "1" +if grep -q 'keel-fab/0.2.0' <<<"$out" && ! grep -q 'keel-fab/0.1.0' <<<"$out"; then + ok "only the released entry below the top is required to have a tag" +else + not_ok "only the released entry below the top is required to have a tag" \ + "$out" +fi + +# --- an older source name is checked under that name -------------------------- + +# This repository's changelog carries fab (1.1.1+keel1) and fab +# (1.1.1+keel2) below the first keel-fab entry. Those versions were built +# and installed, so they are releases and the rule covers them; the tag they +# need is fab/1.1.1+keel1, not keel-fab/1.1.1+keel1. +repo="$(fixture renamed)" +release "$repo" fab 1.1.1+keel1 +tag "$repo" fab/1.1.1+keel1 +release "$repo" fab 1.1.1+keel2 +tag "$repo" fab/1.1.1+keel2 +release "$repo" keel-fab 0.1.0 + +run "$repo" +is "a rename does not orphan the versions released before it" "$status" "0" +if grep -q 'fab/1.1.1+keel2' <<<"$out"; then + ok "a version released under the old source name is checked under it" +else + not_ok "a version released under the old source name is checked under it" \ + "$out" +fi + +# --- unusable input ----------------------------------------------------------- + +repo="$(fixture nochangelog)" +git -C "$repo" commit -q --allow-empty -m empty +run "$repo" +is "a repository with no debian/changelog is an error" "$status" "2" + +mkdir -p "$work/notgit" +run "$work/notgit" +is "a directory that is not a git repository is an error" "$status" "2" + +repo="$(fixture emptychangelog)" +: > "$repo/debian/changelog" +git -C "$repo" add -A +git -C "$repo" commit -qm empty +run "$repo" +is "a changelog with no entries is an error" "$status" "2" + +# --- the default argument ----------------------------------------------------- + +repo="$(fixture default)" +release "$repo" keel-fab 0.1.0 +( cd "$repo" && "$script" >/dev/null 2>&1 ) +is "with no argument it reads the current directory" "$?" "0" + +echo "1..$count" +exit "$failed" From 87161dcbc6c70a6a90c60315367df649ac03696d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 07:56:20 +0000 Subject: [PATCH 3/9] pkg: build as keel-fab, with a version this project chose 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. --- .gitignore | 2 +- debian/changelog | 23 +++++ debian/control | 26 ++++-- debian/rules | 13 +++ fab | 12 +-- fablib/version.py | 33 +++++++ tests/packaging.sh | 212 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 fablib/version.py create mode 100755 tests/packaging.sh diff --git a/.gitignore b/.gitignore index fded7f9..76e9031 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ build/ *.egg-info/ # deb package build assests -debian/fab/ +debian/keel-fab/ debian/.debhelper/ debian/debhelper-build-stamp diff --git a/debian/changelog b/debian/changelog index b572d56..4f29689 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,26 @@ +keel-fab (0.1.0) trixie; urgency=medium + + * This is now a Keel package with a version this project chose, rather + than TurnKey's 1.1.1 with a +keelN suffix on it. The source and binary + package are keel-fab; the repository keeps its upstream name, per + decision 0006, and the upstream history and remote are unchanged. + * Provides, Conflicts and Replaces fab, so a host converts in one + apt-get install and can never carry both. + * Nothing a caller can see is renamed: /usr/bin/fab, the nine fab-* + aliases, /usr/share/fab/product.mk and the fablib python package keep + their names and their paths. buildtasks, the shared makefiles and every + recipe call the commands, never the package. + * Report the version the package was built as, read from + /usr/share/fab/version, instead of running "apt-cache policy fab". That + asked about whatever package is called fab on the machine rather than + about itself, so after the rename it would have answered "(none)", and + bt-layer writes the answer into every layer manifest as fab_version. + * Forked from turnkeylinux/fab 1.1.1. Everything released under the old + name stays in this changelog below, and the tags fab/1.1.1+keel1 and + fab/1.1.1+keel2 name the commits those two packages were built from. + + -- Keel Linux maintainers Mon, 28 Sep 2026 08:00:00 +0000 + fab (1.1.1+keel2) trixie; urgency=low * Apply the units before the common removelists, and let a build select diff --git a/debian/control b/debian/control index afef00a..5a18183 100644 --- a/debian/control +++ b/debian/control @@ -1,6 +1,6 @@ -Source: fab +Source: keel-fab Section: misc -Maintainer: Stefan Davis +Maintainer: Keel Linux maintainers Build-Depends: debhelper-compat (= 13), python3-all (>= 3.13), @@ -10,10 +10,10 @@ Build-Depends: python3-debian, Standards-Version: 4.7.2 X-Python3-Version: >= 3.13 -Vcs-Git: https://github.com/turnkeylinux/fab.git -Vcs-Browser: https://github.com/turnkeylinux/fab +Vcs-Git: https://github.com/Keel-Linux/fab.git +Vcs-Browser: https://github.com/Keel-Linux/fab -Package: fab +Package: keel-fab Architecture: all Depends: cpp, @@ -25,7 +25,21 @@ Depends: xorriso, squashfs-tools, pool, +Provides: fab +Conflicts: fab +Replaces: fab Suggests: dd, wodim, -Description: TurnKey GNU/Linux product fabrication framework +Description: Keel Linux product fabrication framework + fab builds a product from a plan, an overlay and a set of conf scripts: it + resolves the plan against an archive, installs it into a chroot, applies + the overlays and the removelists, and packs the result. + . + Everything a caller can see is unchanged from the TurnKey package this one + replaces: /usr/bin/fab and the fab-chroot, fab-apply-overlay, + fab-apply-patch, fab-apply-removelist, fab-cpp, fab-install, + fab-plan-annotate, fab-plan-resolve and fab-query aliases, the makefile + fragment at /usr/share/fab/product.mk, and the fablib python package. Only + the Debian package is renamed, so buildtasks, the shared makefiles and + every recipe keep working without a change. diff --git a/debian/rules b/debian/rules index adf0870..e5d057a 100755 --- a/debian/rules +++ b/debian/rules @@ -2,5 +2,18 @@ export PYTHONDONTWRITEBYTECODE=1 +# The version the package reports is the version it was built as. fab used +# to answer --version by running "apt-cache policy fab", which asks about +# whatever package is called fab on this machine rather than about itself: +# after the rename that is a different package or none at all, and on a host +# that installed a .deb by file apt has no answer either way. bt-layer +# writes that answer into every layer manifest as fab_version, so it has to +# come from here, where the changelog is the only authority. +DEB_VERSION := $(shell dpkg-parsechangelog -SVersion) + %: dh $@ --with python3 --buildsystem=pybuild + +override_dh_install: + dh_install + echo '$(DEB_VERSION)' > debian/keel-fab/usr/share/fab/version diff --git a/fab b/fab index e5a5ae8..0791b3e 100755 --- a/fab +++ b/fab @@ -30,6 +30,7 @@ from debian import deb822 from fablib import annotate, cpp, removelist, resolve from fablib.installer import Installer, LiveInstaller, PoolInstaller from fablib.plan import Dependency, Plan +from fablib.version import package_version log_level = { "DEBUG": logging.DEBUG, @@ -50,14 +51,7 @@ class SupportsStr(Protocol): def get_version() -> NoReturn: - ver = subprocess.run( - ["apt-cache", "policy", "fab"], capture_output=True, text=True - ).stdout.split() - if ver and len(ver) > 2: - print(ver[2]) - else: - print("Could not detect version via apt.", file=sys.stderr) - print("unknown") + print(package_version()) sys.exit(0) @@ -439,7 +433,7 @@ Configuration environment variables: "-v", "--version", action="store_true", - help="Echo version (from apt) and exit", + help="Echo the version this package was built as and exit", ) subparsers = parser.add_subparsers(dest="command") diff --git a/fablib/version.py b/fablib/version.py new file mode 100644 index 0000000..190e380 --- /dev/null +++ b/fablib/version.py @@ -0,0 +1,33 @@ +"""The version this package was built as. + +Read from the file debian/rules writes into the package, never from apt. +Asking apt was the old answer and it asked the wrong question: "apt-cache +policy fab" reports whatever package is called fab on this machine, which +after the rename to keel-fab is a different package or none, and a host that +installed the .deb from a file has no archive entry to report at all. + +bt-layer records this string in every layer manifest as fab_version, so a +wrong answer here is a wrong provenance record on every image built +afterwards. +""" + +import os +from pathlib import Path + +DEFAULT_SHARE_PATH = "/usr/share/fab" +VERSION_FILE = "version" +UNKNOWN = "unknown" + + +def share_path() -> Path: + """Where the package put its data, overridable for tests.""" + return Path(os.getenv("FAB_SHARE_PATH") or DEFAULT_SHARE_PATH) + + +def package_version() -> str: + """The recorded version, or UNKNOWN when there is nothing to read.""" + try: + recorded = (share_path() / VERSION_FILE).read_text() + except OSError: + return UNKNOWN + return recorded.strip() or UNKNOWN diff --git a/tests/packaging.sh b/tests/packaging.sh new file mode 100755 index 0000000..cc3a1f7 --- /dev/null +++ b/tests/packaging.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# Packaging test: the package this repository builds is the Keel one, it +# takes over from the TurnKey one cleanly, and the rename changes nothing a +# caller can see. +# +# The second half is the point. Roughly 380 call sites across this +# organization name fab-chroot, fab-apply-overlay, FAB_PATH or +# /usr/share/fab, in buildtasks, in the shared makefiles and in every recipe +# Makefile. None of them reads the Debian package name, so the rename is +# invisible to all of them, provided the package still ships the same paths. +# debhelper keys .install, .links and .docs on the binary package name, so a +# rename that forgets to move those files produces a package containing no +# /usr/bin/fab* and no /usr/share/fab, and the first fab-chroot of the next +# build fails. Every shipped path is asserted here for that reason. +# +# Nothing is built and nothing is installed: the checks read debian/, the +# entry point and pyproject.toml, and drive fablib/version.py in a scratch +# directory. Output is TAP; the exit status is the number of failed checks. +# +# tests/packaging.sh + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# shellcheck source=tests/tap.sh +. "$here/tap.sh" + +control="$root/debian/control" +changelog="$root/debian/changelog" + +# field FILE NAME: the value of a debian control field, first occurrence. +field() { + awk -v name="$2:" '$1 == name { $1 = ""; sub(/^ /, ""); print; exit }' "$1" +} + +# binary_field NAME: a field of the binary package stanza, which is the one +# after the blank line that ends the source stanza. +binary_field() { + awk -v name="$1:" ' + /^$/ { binary = 1 } + binary && $1 == name { $1 = ""; sub(/^ /, ""); print; exit } + ' "$control" +} + +# --- the package is ours ------------------------------------------------------ + +is "the source package is keel-fab" "$(field "$control" Source)" "keel-fab" +is "the binary package is keel-fab" "$(binary_field Package)" "keel-fab" + +top="$(head -1 "$changelog")" +is "the changelog names the same source" "${top%% *}" "keel-fab" + +version="$(sed -nE '1s/^[^ ]+ \(([^)]+)\).*/\1/p' "$changelog")" +if [[ "$version" == *+keel* ]]; then + not_ok "the version is ours, not upstream's with a suffix" \ + "the top changelog version is $version; a package this project owns +carries a version this project chose, with no +keelN suffix on somebody +else's version string" +else + ok "the version is ours, not upstream's with a suffix" +fi + +if [[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then + ok "the version is a plain Debian version" +else + not_ok "the version is a plain Debian version" "got: $version" +fi + +maintainer="$(field "$control" Maintainer)" +if [[ "$maintainer" == *turnkeylinux.org* ]]; then + not_ok "the maintainer is this project" \ + "a package this project builds and publishes cannot list another +project's maintainer as the person to report its bugs to; got: $maintainer" +else + ok "the maintainer is this project" +fi + +# --- it takes over from the TurnKey package, and the two never coexist -------- + +for relation in Provides Conflicts Replaces; do + value="$(binary_field "$relation")" + if [[ ",${value// /}," == *,fab,* ]]; then + ok "the package declares $relation on fab" + else + not_ok "the package declares $relation on fab" "got: ${value:-}" + fi +done + +# --- the shipped surface is unchanged ----------------------------------------- + +for helper in install links docs; do + if [[ -f "$root/debian/keel-fab.$helper" ]]; then + ok "debian/keel-fab.$helper is named for the binary package" + else + not_ok "debian/keel-fab.$helper is named for the binary package" \ + "debhelper keys this file on the binary package name; without it +dh_$helper does nothing and the package ships no $helper" + fi +done + +stale="$(find "$root/debian" -maxdepth 1 -name 'fab.*' -printf '%f\n')" +is "no debhelper file is left under the old package name" "$stale" "" + +install_file="$root/debian/keel-fab.install" +if grep -qE '^share/\*[[:space:]]+usr/share/fab$' "$install_file" 2>/dev/null; then + ok "share/ is still installed into /usr/share/fab" +else + not_ok "share/ is still installed into /usr/share/fab" \ + "$(cat "$install_file" 2>/dev/null)" +fi + +if grep -qE '^contrib/fab-\*[[:space:]]+usr/bin$' "$install_file" 2>/dev/null; then + ok "the contrib commands are still installed into /usr/bin" +else + not_ok "the contrib commands are still installed into /usr/bin" \ + "$(cat "$install_file" 2>/dev/null)" +fi + +# The nine names the dispatcher answers to. product.mk and the shared +# makefiles call them unqualified on PATH; losing one is losing a build +# phase. +links_file="$root/debian/keel-fab.links" +for command in fab-query fab-cpp fab-chroot fab-install fab-plan-annotate \ + fab-plan-resolve fab-apply-overlay fab-apply-patch \ + fab-apply-removelist; do + if grep -qE "^/usr/bin/fab[[:space:]]+/usr/bin/$command\$" "$links_file" \ + 2>/dev/null; then + ok "/usr/bin/$command still links to /usr/bin/fab" + else + not_ok "/usr/bin/$command still links to /usr/bin/fab" \ + "$(cat "$links_file" 2>/dev/null)" + fi +done + +strays="$(grep -cvE '^/usr/bin/fab[[:space:]]+/usr/bin/fab-' "$links_file" \ + 2>/dev/null)" +is "every declared link is a /usr/bin/fab-* alias of /usr/bin/fab" \ + "$strays" "0" + +if grep -qE '^script-files = \["fab"\]$' "$root/pyproject.toml"; then + ok "the dispatcher is still installed as /usr/bin/fab" +else + not_ok "the dispatcher is still installed as /usr/bin/fab" \ + "$(grep script-files "$root/pyproject.toml")" +fi + +# --- the version it reports is the version it was built as -------------------- + +# It used to run "apt-cache policy fab". That answers a question about +# whatever package is called fab on this machine, which after the rename is +# either nothing or, if Provides is read, "(none)". bt-layer writes the +# answer into every layer manifest as fab_version, so a wrong answer there +# is a wrong provenance record on every image built afterwards. +if grep -q 'apt-cache' "$root/fab"; then + not_ok "the entry point does not ask apt for its own version" \ + "$(grep -n 'apt-cache' "$root/fab")" +else + ok "the entry point does not ask apt for its own version" +fi + +if grep -qE 'dpkg-query|dpkg -s' "$root/fab"; then + not_ok "the entry point does not name a Debian package to find its version" \ + "$(grep -nE 'dpkg-query|dpkg -s' "$root/fab")" +else + ok "the entry point does not name a Debian package to find its version" +fi + +rules="$root/debian/rules" +if grep -q 'dpkg-parsechangelog' "$rules" \ + && grep -q 'usr/share/fab/version' "$rules"; then + ok "the build records the changelog version in the package" +else + not_ok "the build records the changelog version in the package" \ + "$(cat "$rules")" +fi + +# fablib/version.py is what reads it back. It imports os and pathlib only, +# so it can be driven here without the chroot and python3-debian modules the +# entry point needs. +recorded() { + FAB_SHARE_PATH="$1" python3 -c \ + 'from fablib.version import package_version; print(package_version())' \ + 2>&1 +} + +mkdir -p "$work/share" "$work/empty" +cd "$root" || exit 3 + +echo "0.1.0" > "$work/share/version" +is "the recorded version is what fab reports" "$(recorded "$work/share")" "0.1.0" + +printf ' 2.3.4 \n' > "$work/share/version" +is "surrounding whitespace is not part of the version" \ + "$(recorded "$work/share")" "2.3.4" + +is "an absent version file reports unknown, not a wrong answer" \ + "$(recorded "$work/empty")" "unknown" + +: > "$work/share/version" +is "an empty version file reports unknown" "$(recorded "$work/share")" "unknown" + +is "the default share path is the packaged one" \ + "$(env -u FAB_SHARE_PATH python3 -c \ + 'from fablib.version import share_path; print(share_path())')" \ + "/usr/share/fab" + +echo "1..$count" +exit "$failed" From 53438e629df0a9a87cfab8648cdde9f8cb2154e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 07:56:33 +0000 Subject: [PATCH 4/9] ci: require a changelog entry, and check the release tags 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. --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86b6707..d1397f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,3 +11,33 @@ jobs: uses: keel-linux/.github/.github/workflows/test-shell.yml@main with: threshold: 100 + + # A change that ships must come with a changelog entry, or it cannot be + # installed and never reaches a machine. This repository had no such gate, + # which is how pull requests #4 and #5 changed share/product.mk, a path + # this package ships, with no entry: the version was bumped at packaging + # time instead, and the build host ran 1.1.1+keel2 for three hours before + # any branch carried it (#7). The rule and its tests are + # bin/require-changelog of the .github repository. This job produces the + # check "package / changelog"; it runs on pull requests only, because + # require-changelog compares the two commits of the pull request and has + # nothing to compare outside one. + package: + if: github.event_name == 'pull_request' + uses: keel-linux/.github/.github/workflows/require-changelog.yml@main + + # Every version this repository has released must name the commit it was + # built from: a layer manifest records the builder as a version string and + # nothing else, so the string is provenance only if a tag resolves it. + # bin/check-release-tags states the rule and exempts the newest entry of + # the changelog, which is the release a pull request is proposing. + release-tags: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + # Tags are the thing under test, and the default shallow checkout + # fetches none of them. + fetch-depth: 0 + - run: bin/check-release-tags From 190ce9bcc982a10a6ad37395a6c03a878d6002cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 07:56:33 +0000 Subject: [PATCH 5/9] docs: the measured coverage of the four suites 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. --- COVERAGE.md | 41 +++++++++++++++++++++++++++++++++++++++++ tests/coverage.sh | 18 ++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index 9b238a9..f867830 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -31,6 +31,47 @@ tell that a unit whose `conf` is not executable is skipped, which a `make -n` of the same recipe cannot, since the `[ -x ]` guard is resolved by the shell and not by make. +## 2026-09-28: the package, and the tag that resolves a manifest + +`tests/coverage.sh` now runs four suites: + +| Suite | Checks | What it measures | +|-------|--------|------------------| +| tests/source-date-epoch.sh | 19 of 19 | the `SOURCE_DATE_EPOCH` handling | +| tests/units.sh | 56 of 56 | the unit loop, `UNITS`, the position of the units in `root.patched`, the per-unit removelist and `UNIT_CONF_VARS` | +| tests/packaging.sh | 34 of 34 | the package identity and relationships, every path the package ships, and `fablib/version.py` | +| tests/release-tags.sh | 15 of 15 | `bin/check-release-tags`: each verdict and each exit code | + +Total 124 of 124, 100 percent. The gate stays at 100. + +`tests/packaging.sh` exists for one reason. Roughly 380 call sites across +this organization name `fab-chroot`, `fab-apply-overlay`, `FAB_PATH` or +`/usr/share/fab`, and none of them reads the Debian package name, so +renaming the package to `keel-fab` is invisible to all of them, provided +the package still ships the same paths. debhelper keys `.install`, `.links` +and `.docs` on the binary package name, so a rename that forgets to move +those three files builds a package with no `/usr/bin/fab*` and no +`/usr/share/fab` at all, and the failure appears at the first `fab-chroot` +of the next build rather than at packaging time. Each of the nine +`/usr/bin/fab-*` aliases is therefore one check of its own, and so is each +line of the install file. + +The five branches of `fablib/version.py` (absent file, empty file, +whitespace, a value, and the default and overridden share path) are driven +directly rather than through the `fab` entry point, which imports `chroot` +and `python3-debian` and so cannot run on the coverage runner. + +`tests/release-tags.sh` builds throwaway git repositories under `mktemp -d` +and runs the script against them, so the suite does not depend on the tags +of this repository: a tagged history, a missing tag, a tag on the wrong +commit, an `UNRELEASED` entry, a source rename, and the three unusable +inputs each get a check. + +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 it was written +down. + ## Baseline before the merge: 0 percent, nothing measured `tests/` holds `regtest.sh` (77 lines), `override.sh`, `parseopts.py` and diff --git a/tests/coverage.sh b/tests/coverage.sh index 80e006b..f9df877 100755 --- a/tests/coverage.sh +++ b/tests/coverage.sh @@ -1,14 +1,20 @@ #!/bin/bash -# Coverage of the project-authored code in share/product.mk: -# tests/source-date-epoch.sh the SOURCE_DATE_EPOCH handling -# tests/units.sh the unit loop and UNITS +# Coverage of the project-authored code: +# tests/source-date-epoch.sh share/product.mk, the SOURCE_DATE_EPOCH +# handling +# tests/units.sh share/product.mk, the unit loop and UNITS +# tests/packaging.sh debian/, fablib/version.py: the package this +# repository builds and the surface it ships +# tests/release-tags.sh bin/check-release-tags # # make has no line coverage tool, so the number here is the share of the # checks in those scripts that pass: one check per effect of the touched # code (both arms of the ifneq, both xorriso recipes, isohybrid, # FAB_CHROOT_ENV, fab-plan-resolve and the untouched mksquashfs options for # the first; each unit input, each position in root.patched, each value of -# UNITS and each failure path for the second). +# UNITS and each failure path for the second; each field of the package +# identity, each shipped path and each branch of fablib/version.py for the +# third; each verdict and each exit code of the script for the fourth). # Exits 1 when that share is below the threshold (default 95). # # tests/coverage.sh [THRESHOLD] (or COVERAGE_THRESHOLD in the environment) @@ -17,7 +23,7 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" threshold="${1:-${COVERAGE_THRESHOLD:-95}}" -suites=(source-date-epoch units) +suites=(source-date-epoch units packaging release-tags) total=0 passed=0 @@ -31,7 +37,7 @@ done percent="$(awk -v p="$passed" -v t="$total" 'BEGIN { printf "%.2f", (t ? 100 * p / t : 0) }')" -echo "product.mk: $percent percent ($passed of $total checks) covered, threshold $threshold" +echo "project code: $percent percent ($passed of $total checks) covered, threshold $threshold" if ! awk -v p="$percent" -v t="$threshold" 'BEGIN { exit !(p + 0 >= t + 0) }'; then echo "coverage below threshold" >&2 exit 1 From 21df014460d821e08bcc827e2aa1861718037b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 08:08:43 +0000 Subject: [PATCH 6/9] fix: keep /usr/share/fab a private python directory after the rename 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. --- COVERAGE.md | 15 +++++++++++++-- debian/rules | 20 ++++++++++++++++++++ tests/packaging.sh | 14 ++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index f867830..754b6c0 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -39,10 +39,10 @@ by the shell and not by make. |-------|--------|------------------| | tests/source-date-epoch.sh | 19 of 19 | the `SOURCE_DATE_EPOCH` handling | | tests/units.sh | 56 of 56 | the unit loop, `UNITS`, the position of the units in `root.patched`, the per-unit removelist and `UNIT_CONF_VARS` | -| tests/packaging.sh | 34 of 34 | the package identity and relationships, every path the package ships, and `fablib/version.py` | +| tests/packaging.sh | 35 of 35 | the package identity and relationships, every path the package ships, and `fablib/version.py` | | tests/release-tags.sh | 15 of 15 | `bin/check-release-tags`: each verdict and each exit code | -Total 124 of 124, 100 percent. The gate stays at 100. +Total 125 of 125, 100 percent. The gate stays at 100. `tests/packaging.sh` exists for one reason. Roughly 380 call sites across this organization name `fab-chroot`, `fab-apply-overlay`, `FAB_PATH` or @@ -61,6 +61,17 @@ whitespace, a value, and the default and overridden share path) are driven directly rather than through the `fab` entry point, which imports `chroot` and `python3-debian` and so cannot run on the coverage runner. +What the suite cannot assert is that the built package is the same package, +so that was measured instead, by building both in a `debian:trixie` +container and comparing. `keel-fab 0.1.0` against the `fab 1.1.1+keel2` +installed on the build host: the same 26 paths, 24 of them byte identical, +`/usr/bin/fab` differing only by the `get_version` change and +`runtime.d/*.rtupdate` only by the package name inside it, plus the two new +files `fablib/version.py` and `/usr/share/fab/version`. All nine +`/usr/bin/fab-*` symlinks and `share/product.mk` are byte identical. The +two `debian/rules` overrides were each measured on their own and together, +which is how the `dh_python3` interaction below was found. + `tests/release-tags.sh` builds throwaway git repositories under `mktemp -d` and runs the script against them, so the suite does not depend on the tags of this repository: a tagged history, a missing tag, a tag on the wrong diff --git a/debian/rules b/debian/rules index e5d057a..82272be 100755 --- a/debian/rules +++ b/debian/rules @@ -17,3 +17,23 @@ DEB_VERSION := $(shell dpkg-parsechangelog -SVersion) override_dh_install: dh_install echo '$(DEB_VERSION)' > debian/keel-fab/usr/share/fab/version + +# dh_python3 finds a private directory by the binary package name, so while +# the package was called fab it picked up /usr/share/fab on its own. The +# package is keel-fab now and the directory keeps its name, because +# FAB_SHARE_PATH and nine call sites point at it, so the directory has to be +# named here. Without this the rename silently drops the byte-compilation +# and the shebang rewrite that the fab package did, and the package differs +# from the one it replaces for no reason anybody wrote down. +# +# The two calls are both needed and the order matters. The bare call is what +# moves fablib from /usr/lib/python3.13/dist-packages, where pybuild put it, +# to the version independent /usr/lib/python3/dist-packages; giving +# dh_python3 a directory makes it process that directory instead, so a +# single call with the argument silently pins fablib to one python version. +# Measured by building all three variants: bare only loses the runtime.d +# registration, argument only moves dist-packages, both give a package whose +# paths are identical to fab 1.1.1+keel2. +override_dh_python3: + dh_python3 + dh_python3 /usr/share/fab diff --git a/tests/packaging.sh b/tests/packaging.sh index cc3a1f7..d208224 100755 --- a/tests/packaging.sh +++ b/tests/packaging.sh @@ -178,6 +178,20 @@ else "$(cat "$rules")" fi +# dh_python3 finds a private directory by the binary package name, so it +# picked up /usr/share/fab on its own while the package was called fab. +# Measured by building both: without the directory named explicitly, the +# keel-fab package loses the byte-compilation registration in +# /usr/share/python3/runtime.d and the shebang rewrite of the two scripts +# under /usr/share/fab, and so differs from the package it replaces for a +# reason nobody chose. +if grep -q 'dh_python3 /usr/share/fab' "$rules"; then + ok "the build still treats /usr/share/fab as a private python directory" +else + not_ok "the build still treats /usr/share/fab as a private python directory" \ + "$(cat "$rules")" +fi + # fablib/version.py is what reads it back. It imports os and pathlib only, # so it can be driven here without the chroot and python3-debian modules the # entry point needs. From aecd1fee369f9c2bb83cbbdecb62203754fbb595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 08:56:06 +0000 Subject: [PATCH 7/9] pkg: 2.0.0, because a changelog is one series however the source is renamed 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. --- COVERAGE.md | 22 ++++++++++++---------- debian/changelog | 11 ++++++++++- tests/packaging.sh | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index 754b6c0..63b4e90 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -39,10 +39,10 @@ by the shell and not by make. |-------|--------|------------------| | tests/source-date-epoch.sh | 19 of 19 | the `SOURCE_DATE_EPOCH` handling | | tests/units.sh | 56 of 56 | the unit loop, `UNITS`, the position of the units in `root.patched`, the per-unit removelist and `UNIT_CONF_VARS` | -| tests/packaging.sh | 35 of 35 | the package identity and relationships, every path the package ships, and `fablib/version.py` | +| tests/packaging.sh | 36 of 36 | the package identity and relationships, every path the package ships, and `fablib/version.py` | | tests/release-tags.sh | 15 of 15 | `bin/check-release-tags`: each verdict and each exit code | -Total 125 of 125, 100 percent. The gate stays at 100. +Total 126 of 126, 100 percent. The gate stays at 100. `tests/packaging.sh` exists for one reason. Roughly 380 call sites across this organization name `fab-chroot`, `fab-apply-overlay`, `FAB_PATH` or @@ -63,14 +63,16 @@ and `python3-debian` and so cannot run on the coverage runner. What the suite cannot assert is that the built package is the same package, so that was measured instead, by building both in a `debian:trixie` -container and comparing. `keel-fab 0.1.0` against the `fab 1.1.1+keel2` -installed on the build host: the same 26 paths, 24 of them byte identical, -`/usr/bin/fab` differing only by the `get_version` change and -`runtime.d/*.rtupdate` only by the package name inside it, plus the two new -files `fablib/version.py` and `/usr/share/fab/version`. All nine -`/usr/bin/fab-*` symlinks and `share/product.mk` are byte identical. The -two `debian/rules` overrides were each measured on their own and together, -which is how the `dh_python3` interaction below was found. +container and comparing. `keel-fab 2.0.0` against the `fab 1.1.1+keel2` +installed on the build host: **all 28 paths the old package had are present, +26 of them byte identical**, including all nine `/usr/bin/fab-*` symlinks +and `share/product.mk`. The two that differ are `/usr/bin/fab`, by the +`get_version` change alone, and `runtime.d/*.rtupdate`, by the package name +inside it. Two files are new, `fablib/version.py` and +`/usr/share/fab/version`. Nothing is missing. + +The two `debian/rules` overrides were each measured on their own and +together, which is how the `dh_python3` interaction below was found. `tests/release-tags.sh` builds throwaway git repositories under `mktemp -d` and runs the script against them, so the suite does not depend on the tags diff --git a/debian/changelog b/debian/changelog index 4f29689..0c1110b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,9 +1,18 @@ -keel-fab (0.1.0) trixie; urgency=medium +keel-fab (2.0.0) trixie; urgency=medium * This is now a Keel package with a version this project chose, rather than TurnKey's 1.1.1 with a +keelN suffix on it. The source and binary package are keel-fab; the repository keeps its upstream name, per decision 0006, and the upstream history and remote are unchanged. + * 2.0.0 rather than a reset to 0.1.0. A changelog is one monotonic series + whatever the source name does: the organization's require-changelog gate + compares the proposed top version against the base's with + dpkg --compare-versions gt, and reprepro and dpkg-genchanges read the + file the same way. 0.1.0 was proposed first and refused, correctly, as + lower than 1.1.1+keel2. A major bump is also the honest signal: the + package name, the numbering and the ownership all change at once, and + this code has been building every layer in production since before it + was ours, so a 0.x would claim it is pre-release. * Provides, Conflicts and Replaces fab, so a host converts in one apt-get install and can never carry both. * Nothing a caller can see is renamed: /usr/bin/fab, the nine fab-* diff --git a/tests/packaging.sh b/tests/packaging.sh index d208224..5d8c850 100755 --- a/tests/packaging.sh +++ b/tests/packaging.sh @@ -70,6 +70,23 @@ else not_ok "the version is a plain Debian version" "got: $version" fi +# A changelog is one monotonic series whatever the source name does. The +# organization's require-changelog gate compares the proposed top version +# against the base's with "dpkg --compare-versions gt", and reprepro and +# dpkg-genchanges read the file the same way, so a rename cannot reset the +# numbering downwards. Measured: 0.1.0 was proposed for this rename first, +# matching the plain scheme the other Keel packages start at, and the gate +# refused it against 1.1.1+keel2. That is why the version is 2.0.0. +previous="$(sed -nE '2,$ s/^[^ ]+ \(([^)]+)\).*/\1/p' "$changelog" | head -1)" +if dpkg --compare-versions "$version" gt "$previous"; then + ok "the version is greater than the entry below it" +else + not_ok "the version is greater than the entry below it" \ + "$version is not greater than $previous; a changelog is one series +however the source is renamed, and require-changelog, reprepro and +dpkg-genchanges all read it that way" +fi + maintainer="$(field "$control" Maintainer)" if [[ "$maintainer" == *turnkeylinux.org* ]]; then not_ok "the maintainer is this project" \ From 6a1f93c1c96d6efd649ff6a377c827c21ed05746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 28 Sep 2026 10:04:57 +0000 Subject: [PATCH 8/9] fix: make the tag rule as strong as the direction it promises, and agree 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//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. --- .github/workflows/tests.yml | 21 ++++- COVERAGE.md | 116 ++++++++++++++++++---------- bin/check-release-tags | 111 +++++++++++++++++++------- debian/rules | 5 +- fablib/version.py | 22 ++++-- pyproject.toml | 22 ++++-- tests/packaging.sh | 49 +++++++++--- tests/release-tags.sh | 150 +++++++++++++++++++++++++++++++----- 8 files changed, 385 insertions(+), 111 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d1397f6..1d761d2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,6 +31,20 @@ jobs: # nothing else, so the string is provenance only if a tag resolves it. # bin/check-release-tags states the rule and exempts the newest entry of # the changelog, which is the release a pull request is proposing. + # Every version this repository has released must name the commit it was + # built from: a layer manifest records the builder as a version string and + # nothing else, so the string is provenance only if a tag resolves it. + # + # Two invocations, because the newest entry means different things in the + # two places. On a pull request it is the release being proposed and the + # commit it will be tagged on does not exist yet. On the default branch it + # does exist, and a merged, built and installed release with no tag is + # exactly the defect #7 was filed about, so --require-newest is what keeps + # this from being that hole one version deep. Push the tag with the merge. + # + # --since is the floor. Below it are the entries this repository was forked + # with, which upstream tags as v1.1.0 and v1.0.3; a later merge from + # upstream must not start demanding tags that are not ours to make. release-tags: runs-on: ubuntu-latest timeout-minutes: 5 @@ -40,4 +54,9 @@ jobs: # Tags are the thing under test, and the default shallow checkout # fetches none of them. fetch-depth: 0 - - run: bin/check-release-tags + - name: Releases below the one being proposed must be tagged + if: github.event_name == 'pull_request' + run: bin/check-release-tags --since 1.1.1+keel1 + - name: Every release must be tagged, including the newest + if: github.event_name != 'pull_request' + run: bin/check-release-tags --since 1.1.1+keel1 --require-newest diff --git a/COVERAGE.md b/COVERAGE.md index 63b4e90..c4967ca 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -39,51 +39,89 @@ by the shell and not by make. |-------|--------|------------------| | tests/source-date-epoch.sh | 19 of 19 | the `SOURCE_DATE_EPOCH` handling | | tests/units.sh | 56 of 56 | the unit loop, `UNITS`, the position of the units in `root.patched`, the per-unit removelist and `UNIT_CONF_VARS` | -| tests/packaging.sh | 36 of 36 | the package identity and relationships, every path the package ships, and `fablib/version.py` | -| tests/release-tags.sh | 15 of 15 | `bin/check-release-tags`: each verdict and each exit code | - -Total 126 of 126, 100 percent. The gate stays at 100. - -`tests/packaging.sh` exists for one reason. Roughly 380 call sites across -this organization name `fab-chroot`, `fab-apply-overlay`, `FAB_PATH` or -`/usr/share/fab`, and none of them reads the Debian package name, so -renaming the package to `keel-fab` is invisible to all of them, provided -the package still ships the same paths. debhelper keys `.install`, `.links` -and `.docs` on the binary package name, so a rename that forgets to move -those three files builds a package with no `/usr/bin/fab*` and no -`/usr/share/fab` at all, and the failure appears at the first `fab-chroot` -of the next build rather than at packaging time. Each of the nine -`/usr/bin/fab-*` aliases is therefore one check of its own, and so is each -line of the install file. +| tests/packaging.sh | 39 of 39 | the package identity and relationships, every path the package ships, the agreement of the four places it states its version, and `fablib/version.py` | +| tests/release-tags.sh | 27 of 27 | `bin/check-release-tags`: each verdict, each exit code, the proposed-release exemption and what revokes it | + +Total 141 of 141, 100 percent. The gate stays at 100. + +**What this number is, and what it is not.** `tests/coverage.sh` computes the +share of checks that pass, so it reads 100 whenever the suites are green and +can only fall when one fails. It is not a line or branch measurement, and +adding a suite to `suites=` does not by itself establish that the suite +exercises anything: the claim rests on the enumeration in each script's +header. `docs/traps.md` already carries "66 tests and 100 percent line +coverage on the same file" as something that misled this project once, and +the two suites added here are the first where a branch could go unexercised +without the number moving. `make` has no line coverage tool, which is why the +measurement is shaped this way, but the shape is worth knowing when reading +the number. + +`tests/packaging.sh` exists for one reason. Roughly 246 references to the +eleven `fab-*` commands, 436 to `FAB_PATH`, `FAB_ARCH` and `FAB_SHARE_PATH`, +and 31 to `/usr/share/fab` across this organization, and none of them reads +the Debian package name, so renaming the package to `keel-fab` is invisible +to all of them, provided it still ships the same paths. debhelper keys +`.install`, `.links` and `.docs` on the binary package name, so a rename that +forgets to move those three files builds a package with no `/usr/bin/fab*` +and no `/usr/share/fab` at all, and the failure appears at the first +`fab-chroot` of the next build rather than at packaging time. Each of the +nine `/usr/bin/fab-*` aliases is therefore one check of its own, and so is +each line of the install file. The five branches of `fablib/version.py` (absent file, empty file, -whitespace, a value, and the default and overridden share path) are driven -directly rather than through the `fab` entry point, which imports `chroot` -and `python3-debian` and so cannot run on the coverage runner. - -What the suite cannot assert is that the built package is the same package, -so that was measured instead, by building both in a `debian:trixie` -container and comparing. `keel-fab 2.0.0` against the `fab 1.1.1+keel2` -installed on the build host: **all 28 paths the old package had are present, -26 of them byte identical**, including all nine `/usr/bin/fab-*` symlinks -and `share/product.mk`. The two that differ are `/usr/bin/fab`, by the -`get_version` change alone, and `runtime.d/*.rtupdate`, by the package name -inside it. Two files are new, `fablib/version.py` and -`/usr/share/fab/version`. Nothing is missing. +whitespace, a value, and the default path) are driven directly rather than +through the `fab` entry point, which imports `chroot` and `python3-debian` +and so cannot run on the coverage runner. A sixth check asserts that the +override variable is `FAB_VERSION_FILE` and not `FAB_SHARE_PATH`: the latter +is a build variable, and one exported `FAB_SHARE_PATH` pointing at a checkout +would otherwise make `bt-layer` write `fab_version unknown` into every +manifest built afterwards. + +`tests/release-tags.sh` builds throwaway git repositories under `mktemp -d` +and runs the script against them, so the suite does not depend on the tags of +this repository. It covers a tagged history, a missing tag, a tag on the +wrong commit, a lightweight tag, a tag on an unreachable orphan commit, an +`UNRELEASED` entry on top, a source rename, the floor, and four unusable +inputs. The lightweight and unreachable cases are there because the reverse +direction the rule promises is `git describe --match '*/*'`, which refuses a +lightweight tag outright: a forward direction that accepted one would enforce +the two halves of the invariant to different standards. + +### What the suite cannot assert, measured instead + +No assertion about `debian/` can say what a build produces, so both packages +were built in a `debian:trixie` container and compared. `keel-fab 2.0.0` +against the `fab 1.1.1+keel2` installed on the build host: + +| | Old | New | +|---|---|---| +| Entries | 35 (26 files, 9 symlinks) | 37 (28 files, 9 symlinks) | + +- **27 entries are at the same path, and 26 of them are byte identical**, + including all nine `/usr/bin/fab-*` symlinks and `share/product.mk`, whose + md5 is `a06bfe03` in both. The one that differs is `/usr/bin/fab`, by the + `get_version` change alone. +- **8 entries are renamed**, every one of them by debhelper keying on the + package name: the four `dist-info` files, the three under + `usr/share/doc/fab/`, and `runtime.d/fab.rtupdate`. Five of the eight are + byte identical under the new name; `changelog.gz` carries the new entry, + `rtupdate` names the package, and `METADATA` names the package and version. +- **2 are new**: `fablib/version.py` and `/usr/share/fab/version`. +- Nothing is dropped. + +The four places the package states its version now agree: dpkg says +`keel-fab 2.0.0`, `/usr/share/fab/version` says `2.0.0`, the `dist-info` +`METADATA` says `keel-fab 2.0.0`, and the changelog says `keel-fab (2.0.0)`. +They did not before: the package shipped `fab-1.1.0.dist-info` while dpkg +called it `fab 1.1.1+keel2`, so `importlib.metadata.version` was a fourth, +wrong answer. `tests/packaging.sh` asserts the agreement now. The two `debian/rules` overrides were each measured on their own and together, which is how the `dh_python3` interaction below was found. -`tests/release-tags.sh` builds throwaway git repositories under `mktemp -d` -and runs the script against them, so the suite does not depend on the tags -of this repository: a tagged history, a missing tag, a tag on the wrong -commit, an `UNRELEASED` entry, a source rename, and the three unusable -inputs each get a check. - -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 it was written -down. +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 it was written down. ## Baseline before the merge: 0 percent, nothing measured diff --git a/bin/check-release-tags b/bin/check-release-tags index a99c403..f7086df 100755 --- a/bin/check-release-tags +++ b/bin/check-release-tags @@ -5,30 +5,46 @@ # 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 names a commit. On 2026-09-27 a package -# was built at 17:14 from a commit that reached the default branch at 19:58 -# and was never tagged, so for three hours the machine that builds every -# layer ran a version no clone could resolve to a commit. +# was built at 17:14:56 UTC from a commit that reached the default branch at +# 19:58:53 UTC and was never tagged, so the machine that builds every layer +# ran a version no clone could resolve to a commit. # # The rule this enforces: # -# - every entry of debian/changelog whose distribution is not UNRELEASED -# is a release; -# - the newest release is the one being proposed and may be untagged; -# - every release below it must have a tag /, and the -# tree that tag names must carry at the top of its changelog. +# - every entry of debian/changelog whose distribution is not UNRELEASED, +# and whose version is at or above the floor, is a release; +# - the entry at the top of the file may be untagged, because it is the +# release a pull request is proposing and the commit it will be tagged +# on does not exist yet. --require-newest revokes that, and the default +# branch is where it belongs: a merged, built and installed release with +# no tag is the defect this rule exists for; +# - every other release must have a tag / which is +# annotated, is an ancestor of HEAD, and whose tree carries at +# the top of its changelog. # # The source name comes from each entry, so versions released before a # rename are checked under the name they were released as: fab/1.1.1+keel2, # not keel-fab/1.1.1+keel2. # -# Going the other way needs no extra machinery: "git describe --match -# '*/*'" on a commit says which release it belongs to, and the tag's -# changelog says the version a manifest would record. +# Annotated and reachable are not fussiness. The other direction of the +# invariant is "git describe --match '*/*'", which refuses a lightweight tag +# outright, so accepting one here would enforce the two halves to different +# standards; and a tag on a commit outside the branch names nothing at all. # -# bin/check-release-tags [REPOSITORY] (default: the current directory) +# The floor exists because a fork's changelog carries the upstream entries it +# was forked from, and upstream tags those its own way: fab's are v1.1.0 and +# v1.0.3. Without it, a merge from upstream that brought real entries into +# the file would demand tags that are not ours to make. # -# Exit 0 every release is tagged, 1 a release is untagged or a tag does not -# carry its version, 2 the repository or its changelog cannot be read. +# Known limit: a version with an epoch cannot satisfy this rule, because +# refs/tags//1:2.0.0 is not a legal refname. BRIEF section 7 forbids +# an epoch except as a last resort, and this is one more reason. +# +# bin/check-release-tags [--require-newest] [--since VERSION] [REPOSITORY] +# +# Exit 0 every release is tagged, 1 a release is untagged or its tag does not +# name the right commit, 2 the arguments, the repository or its changelog +# cannot be used. set -uo pipefail @@ -54,12 +70,41 @@ tag_version() { sed -nE '1s/^[a-z0-9][a-z0-9.+-]* \(([^)]+)\).*/\1/p' } -# has_tag REPOSITORY TAG -has_tag() { - git -C "$1" rev-parse -q --verify "refs/tags/$2" >/dev/null 2>&1 +# tag_problem REPOSITORY TAG: why TAG does not name a release, empty when it +# does. +tag_problem() { + local repository=$1 tag=$2 ref="refs/tags/$2" + + git -C "$repository" rev-parse -q --verify "$ref" >/dev/null 2>&1 || + { echo "is missing"; return; } + + # An annotated tag is its own object; a lightweight one points straight + # at the commit, and git describe will not use it. + [[ "$(git -C "$repository" cat-file -t "$ref" 2>/dev/null)" == tag ]] || + { echo "is a lightweight tag, and a release tag has to be annotated" + return; } + + git -C "$repository" merge-base --is-ancestor "$ref" HEAD 2>/dev/null || + { echo "is not in this branch"; return; } } -repository="${1:-.}" +require_newest=false +since="" +repository="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --require-newest) require_newest=true; shift ;; + --since) [[ $# -ge 2 ]] || die 2 "--since needs a version" + since=$2; shift 2 ;; + --since=*) since=${1#--since=}; shift ;; + -h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) die 2 "unknown option $1" ;; + *) [[ -z "$repository" ]] || die 2 "only one repository at a time" + repository=$1; shift ;; + esac +done +repository="${repository:-.}" git -C "$repository" rev-parse --git-dir >/dev/null 2>&1 || die 2 "$repository is not a git repository" @@ -71,17 +116,26 @@ mapfile -t entries < <(changelog_entries "$changelog") [[ ${#entries[@]} -gt 0 ]] || die 2 "$changelog has no entries" problems=0 -proposed="" +seen_release=false + +for index in "${!entries[@]}"; do + read -r source version distribution <<<"${entries[$index]}" -for entry in "${entries[@]}"; do - read -r source version distribution <<<"$entry" [[ "$distribution" == UNRELEASED ]] && continue + if [[ -n "$since" ]] && ! dpkg --compare-versions "$version" ge "$since" + then + continue + fi + seen_release=true tag="$source/$version" - if [[ -z "$proposed" ]]; then - proposed="$tag" - if has_tag "$repository" "$tag"; then + # Only the entry at the very top of the file is the one being proposed. + # An UNRELEASED entry above a release does not pass the exemption down, + # or the newest real release would stay exempt for as long as somebody + # left a work in progress entry on top. + if [[ $index -eq 0 ]] && ! $require_newest; then + if [[ -z "$(tag_problem "$repository" "$tag")" ]]; then echo "$tag is the newest release and is tagged" else echo "$tag is the release being proposed, not tagged yet" @@ -89,9 +143,10 @@ for entry in "${entries[@]}"; do continue fi - if ! has_tag "$repository" "$tag"; then - printf '%s: %s is missing: %s was released and no tag names the commit it was built from\n' \ - "$PROGRAM" "$tag" "$version" >&2 + problem="$(tag_problem "$repository" "$tag")" + if [[ -n "$problem" ]]; then + printf '%s: %s %s, and %s was released\n' \ + "$PROGRAM" "$tag" "$problem" "$version" >&2 problems=$((problems + 1)) continue fi @@ -107,7 +162,7 @@ for entry in "${entries[@]}"; do echo "$tag names the commit that carries $version" done -[[ -n "$proposed" ]] || die 2 "$changelog has no released entry" +$seen_release || die 2 "$changelog has no released entry at or above ${since:-any version}" [[ $problems -eq 0 ]] || die 1 "$problems released version(s) cannot be traced to a tag" diff --git a/debian/rules b/debian/rules index 82272be..50ba996 100755 --- a/debian/rules +++ b/debian/rules @@ -10,13 +10,16 @@ export PYTHONDONTWRITEBYTECODE=1 # writes that answer into every layer manifest as fab_version, so it has to # come from here, where the changelog is the only authority. DEB_VERSION := $(shell dpkg-parsechangelog -SVersion) +DEB_PACKAGE := $(shell dh_listpackages) %: dh $@ --with python3 --buildsystem=pybuild +# dh_install must have created the directory, so a wrong path here is a loud +# build failure and never a silently empty version file. override_dh_install: dh_install - echo '$(DEB_VERSION)' > debian/keel-fab/usr/share/fab/version + echo '$(DEB_VERSION)' > debian/$(DEB_PACKAGE)/usr/share/fab/version # dh_python3 finds a private directory by the binary package name, so while # the package was called fab it picked up /usr/share/fab on its own. The diff --git a/fablib/version.py b/fablib/version.py index 190e380..06c9090 100644 --- a/fablib/version.py +++ b/fablib/version.py @@ -8,26 +8,34 @@ bt-layer records this string in every layer manifest as fab_version, so a wrong answer here is a wrong provenance record on every image built -afterwards. +afterwards. That is why the override below is FAB_VERSION_FILE and not +FAB_SHARE_PATH: FAB_SHARE_PATH is a build variable, and although product.mk +and turnkey.mk both set it with ?= and neither exports it, one exported +FAB_SHARE_PATH pointing at a checkout would be enough to make every manifest +built afterwards record "unknown". Nothing a build environment sets for its +own reasons may redirect this lookup. """ import os from pathlib import Path -DEFAULT_SHARE_PATH = "/usr/share/fab" -VERSION_FILE = "version" +VERSION_PATH = "/usr/share/fab/version" UNKNOWN = "unknown" -def share_path() -> Path: - """Where the package put its data, overridable for tests.""" - return Path(os.getenv("FAB_SHARE_PATH") or DEFAULT_SHARE_PATH) +def version_file() -> Path: + """The file the package recorded its version in. + + FAB_VERSION_FILE exists for the tests and for nothing else. No build sets + it, and nothing in this repository reads it outside this function. + """ + return Path(os.getenv("FAB_VERSION_FILE") or VERSION_PATH) def package_version() -> str: """The recorded version, or UNKNOWN when there is nothing to read.""" try: - recorded = (share_path() / VERSION_FILE).read_text() + recorded = version_file().read_text() except OSError: return UNKNOWN return recorded.strip() or UNKNOWN diff --git a/pyproject.toml b/pyproject.toml index 82a5425..3523319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,16 +2,28 @@ requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" +# The name and the version here are the third and fourth places this package +# states what it is, after debian/control and debian/changelog. They used to +# disagree: the package said "fab 1.1.0" to importlib.metadata while dpkg +# said "fab 1.1.1+keel2", which is the kind of thing somebody finds while +# looking for something else. They are asserted equal to debian/ by +# tests/packaging.sh now, so there is one name and one version. +# +# Keep the version in step with the top entry of debian/changelog in the same +# commit; the test fails otherwise. [project] -name = "fab" -version = "1.1.0" -authors = [{name = "TurnKey GNU/Linux", email = "jeremy@turnkeylinux.org"}] +name = "keel-fab" +version = "2.0.0" +authors = [{name = "Keel Linux maintainers", email = "admin@keellinux.org"}] license = "GPL-3.0-or-later" -description = "TurnKey GNU/Linux product fabrication framework" +description = "Keel Linux product fabrication framework" [project.urls] -Homepage = "https://github.com/turnkeylinux/fab" +Homepage = "https://github.com/Keel-Linux/fab" +# script-files keeps the entry point installed as /usr/bin/fab. The command +# names are not renamed by any of this: roughly 600 call sites across the +# organization name them, and none reads the package name. [tool.setuptools] packages = ["fablib"] script-files = ["fab"] diff --git a/tests/packaging.sh b/tests/packaging.sh index 5d8c850..60bf24f 100755 --- a/tests/packaging.sh +++ b/tests/packaging.sh @@ -87,6 +87,17 @@ however the source is renamed, and require-changelog, reprepro and dpkg-genchanges all read it that way" fi +# The package states what it is in three places, and they used to disagree: +# the built package told importlib.metadata it was "fab 1.1.0" while dpkg +# called it "fab 1.1.1+keel2", because pyproject.toml was never touched when +# the Debian version was bumped. Three names and versions is how a provenance +# record ends up quoting whichever one the reader happened to ask. +pyproject="$root/pyproject.toml" +py_name="$(sed -nE 's/^name = "([^"]+)"$/\1/p' "$pyproject")" +py_version="$(sed -nE 's/^version = "([^"]+)"$/\1/p' "$pyproject")" +is "pyproject names the same package as debian/control" "$py_name" "keel-fab" +is "pyproject carries the changelog version" "$py_version" "$version" + maintainer="$(field "$control" Maintainer)" if [[ "$maintainer" == *turnkeylinux.org* ]]; then not_ok "the maintainer is this project" \ @@ -213,31 +224,47 @@ fi # so it can be driven here without the chroot and python3-debian modules the # entry point needs. recorded() { - FAB_SHARE_PATH="$1" python3 -c \ + FAB_VERSION_FILE="$1" python3 -c \ 'from fablib.version import package_version; print(package_version())' \ 2>&1 } -mkdir -p "$work/share" "$work/empty" +mkdir -p "$work/share" cd "$root" || exit 3 echo "0.1.0" > "$work/share/version" -is "the recorded version is what fab reports" "$(recorded "$work/share")" "0.1.0" +is "the recorded version is what fab reports" \ + "$(recorded "$work/share/version")" "0.1.0" printf ' 2.3.4 \n' > "$work/share/version" is "surrounding whitespace is not part of the version" \ - "$(recorded "$work/share")" "2.3.4" + "$(recorded "$work/share/version")" "2.3.4" is "an absent version file reports unknown, not a wrong answer" \ - "$(recorded "$work/empty")" "unknown" + "$(recorded "$work/nothing-here")" "unknown" : > "$work/share/version" -is "an empty version file reports unknown" "$(recorded "$work/share")" "unknown" - -is "the default share path is the packaged one" \ - "$(env -u FAB_SHARE_PATH python3 -c \ - 'from fablib.version import share_path; print(share_path())')" \ - "/usr/share/fab" +is "an empty version file reports unknown" \ + "$(recorded "$work/share/version")" "unknown" + +is "the default path is the packaged one" \ + "$(env -u FAB_VERSION_FILE python3 -c \ + 'from fablib.version import version_file; print(version_file())')" \ + "/usr/share/fab/version" + +# The override has to be a name no build sets. FAB_SHARE_PATH is a build +# variable (share/product.mk:60, common/mk/turnkey.mk:26), and while both use +# ?= and neither exports it, one exported FAB_SHARE_PATH pointing at a +# checkout would otherwise be enough to make bt-layer write +# "fab_version unknown" into every manifest built afterwards. +if grep -qE '(getenv|environ)[^)]*FAB_SHARE_PATH' \ + "$root/fablib/version.py" "$root/fab"; then + not_ok "no build variable can redirect the version lookup" \ + "$(grep -nE '(getenv|environ)[^)]*FAB_SHARE_PATH' \ + "$root/fablib/version.py" "$root/fab")" +else + ok "no build variable can redirect the version lookup" +fi echo "1..$count" exit "$failed" diff --git a/tests/release-tags.sh b/tests/release-tags.sh index adf1272..f0716c9 100755 --- a/tests/release-tags.sh +++ b/tests/release-tags.sh @@ -4,13 +4,20 @@ # # Why the rule exists. 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". On 2026-09-27 a package was built at 17:14 from -# a commit that reached the default branch at 19:58 and was never tagged, so -# for three hours the machine that builds every layer ran a version that no -# clone of this repository could name a commit for. A version string is only -# provenance if it maps to a commit, and a tag is what makes that map total. +# "fab_version 1.1.1+keel1". On 2026-09-27 a package was built at 17:14:56 +# UTC from a commit that reached the default branch at 19:58:53 UTC, two +# hours and forty four minutes later, and was never tagged at all. A version +# string is provenance only if it maps to a commit, and a tag is what makes +# that map total. # -# The script is driven here against git repositories built in a scratch +# The rule has to be as strong as the reverse direction it promises. That +# direction is "git describe --match '*/*'", which refuses a lightweight tag +# outright, so a lightweight tag must not satisfy the forward direction +# either; and a tag on a commit that is not in the branch names nothing. +# Both are checked here, as are the exemption for the entry a pull request +# is proposing and the fact that --require-newest revokes it. +# +# The script is driven against git repositories built in a scratch # directory, so the checks do not depend on the tags of this repository. # Output is TAP; the exit status is the number of failed checks. # @@ -54,13 +61,19 @@ release() { git -C "$dir" commit -qm "$source $version" } -# tag DIR NAME: tag the current commit +# tag DIR NAME: an annotated tag on the current commit, the only kind the +# rule accepts tag() { + git -C "$1" tag -a -m "release $2" "$2" +} + +# lightweight_tag DIR NAME +lightweight_tag() { git -C "$1" tag "$2" } -# run DIR...: run the script, leaving stdout and stderr in $out and the -# exit status in $status +# run ARG...: run the script, leaving its output in $out and its exit status +# in $status run() { out="$("$script" "$@" 2>&1)" status=$? @@ -88,6 +101,42 @@ else not_ok "it says the top entry is the one being proposed" "$out" fi +# --- the exemption for the proposed release, and revoking it ------------------ + +# On a pull request the newest entry cannot be tagged, because the commit it +# will be tagged on does not exist yet. On the default branch it can, and +# must: a merged and built but untagged release is the state issue #7 was +# filed about, one version further on. +run --require-newest "$repo" +is "--require-newest refuses an untagged newest release" "$status" "1" +if grep -q 'keel-fab/0.3.0' <<<"$out"; then + ok "it names the newest release as the one missing a tag" +else + not_ok "it names the newest release as the one missing a tag" "$out" +fi + +tag "$repo" keel-fab/0.3.0 +run --require-newest "$repo" +is "--require-newest passes once the newest release is tagged" "$status" "0" + +# An UNRELEASED entry on top must not confer the exemption on the release +# below it, or the newest real release stays exempt for as long as somebody +# leaves a work in progress entry at the top of the file. +repo="$(fixture unreleased_top)" +release "$repo" keel-fab 0.1.0 +tag "$repo" keel-fab/0.1.0 +release "$repo" keel-fab 0.2.0 +release "$repo" keel-fab 0.3.0 UNRELEASED + +run "$repo" +is "an UNRELEASED entry on top does not exempt the release below it" \ + "$status" "1" +if grep -q 'keel-fab/0.2.0' <<<"$out" && ! grep -q 'keel-fab/0.3.0' <<<"$out"; then + ok "the UNRELEASED entry is not itself required to be tagged" +else + not_ok "the UNRELEASED entry is not itself required to be tagged" "$out" +fi + # --- a released version with no tag ------------------------------------------- repo="$(fixture untagged)" @@ -119,21 +168,51 @@ else not_ok "the failure says which version the tag actually carries" "$out" fi -# --- entries that are not releases -------------------------------------------- +# --- a lightweight tag is not a release --------------------------------------- -repo="$(fixture unreleased)" -release "$repo" keel-fab 0.1.0 UNRELEASED +# The reverse direction this rule promises is "git describe --match '*/*'", +# and git describe refuses a lightweight tag: "No annotated tags can +# describe". A forward direction that accepts one is weaker than the reverse +# direction it is paired with, so it does not. +repo="$(fixture lightweight)" +release "$repo" keel-fab 0.1.0 +lightweight_tag "$repo" keel-fab/0.1.0 release "$repo" keel-fab 0.2.0 -release "$repo" keel-fab 0.3.0 run "$repo" -is "a released entry below the top is still required to be tagged" \ +is "a lightweight tag does not satisfy the rule" "$status" "1" +if grep -qi 'annotated' <<<"$out"; then + ok "the failure says the tag has to be annotated" +else + not_ok "the failure says the tag has to be annotated" "$out" +fi + +# --- a tag that is not in this branch ----------------------------------------- + +repo="$(fixture unreachable)" +release "$repo" keel-fab 0.1.0 +release "$repo" keel-fab 0.2.0 +# An orphan branch with entirely different content, tagged as if it were the +# 0.1.0 release. Its changelog says 0.1.0, so only reachability catches it. +git -C "$repo" checkout -q --orphan elsewhere +git -C "$repo" rm -q -rf . >/dev/null 2>&1 +mkdir -p "$repo/debian" +{ + printf 'keel-fab (0.1.0) trixie; urgency=low\n\n * not the same code\n\n' + printf ' -- Tests Mon, 01 Jan 2029 00:00:00 +0000\n' +} > "$repo/debian/changelog" +git -C "$repo" add -A +git -C "$repo" commit -qm orphan +tag "$repo" keel-fab/0.1.0 +git -C "$repo" checkout -q main + +run "$repo" +is "a tag that is not an ancestor of HEAD does not satisfy the rule" \ "$status" "1" -if grep -q 'keel-fab/0.2.0' <<<"$out" && ! grep -q 'keel-fab/0.1.0' <<<"$out"; then - ok "only the released entry below the top is required to have a tag" +if grep -qi 'not in this branch' <<<"$out"; then + ok "the failure says the tag is not in this branch" else - not_ok "only the released entry below the top is required to have a tag" \ - "$out" + not_ok "the failure says the tag is not in this branch" "$out" fi # --- an older source name is checked under that name -------------------------- @@ -147,7 +226,7 @@ release "$repo" fab 1.1.1+keel1 tag "$repo" fab/1.1.1+keel1 release "$repo" fab 1.1.1+keel2 tag "$repo" fab/1.1.1+keel2 -release "$repo" keel-fab 0.1.0 +release "$repo" keel-fab 2.0.0 run "$repo" is "a rename does not orphan the versions released before it" "$status" "0" @@ -158,6 +237,31 @@ else "$out" fi +# --- the floor ---------------------------------------------------------------- + +# A fork's changelog carries the upstream entries it was forked from, and +# upstream tags those its own way: fab's are v1.1.0 and v1.0.3, not +# fab/1.1.0. Without a floor the rule would demand a tag for every version +# anybody ever released, and a merge from upstream that brought real entries +# into the file would break the build. +repo="$(fixture floor)" +release "$repo" fab 1.0.3 +release "$repo" fab 1.1.0 +release "$repo" keel-fab 2.0.0 +tag "$repo" keel-fab/2.0.0 +release "$repo" keel-fab 2.1.0 + +run --since 2.0.0 "$repo" +is "entries below the floor are not required to be tagged" "$status" "0" +if ! grep -q '1\.1\.0' <<<"$out"; then + ok "an entry below the floor is not mentioned at all" +else + not_ok "an entry below the floor is not mentioned at all" "$out" +fi + +run "$repo" +is "without a floor every entry below the newest is a release" "$status" "1" + # --- unusable input ----------------------------------------------------------- repo="$(fixture nochangelog)" @@ -176,6 +280,14 @@ git -C "$repo" commit -qm empty run "$repo" is "a changelog with no entries is an error" "$status" "2" +repo="$(fixture allunreleased)" +release "$repo" keel-fab 0.1.0 UNRELEASED +run "$repo" +is "a changelog with no released entry is an error" "$status" "2" + +run --nonsense "$repo" +is "an unknown option is an error" "$status" "2" + # --- the default argument ----------------------------------------------------- repo="$(fixture default)" From 7fb3f3d6cee5f72508ec7896841a175047f3bf6c Mon Sep 17 00:00:00 2001 From: navigator Date: Tue, 29 Sep 2026 03:18:20 +0000 Subject: [PATCH 9/9] ci: say once why the release tags are checked 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. --- .github/workflows/tests.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1d761d2..2aebd16 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,11 +26,6 @@ jobs: if: github.event_name == 'pull_request' uses: keel-linux/.github/.github/workflows/require-changelog.yml@main - # Every version this repository has released must name the commit it was - # built from: a layer manifest records the builder as a version string and - # nothing else, so the string is provenance only if a tag resolves it. - # bin/check-release-tags states the rule and exempts the newest entry of - # the changelog, which is the release a pull request is proposing. # Every version this repository has released must name the commit it was # built from: a layer manifest records the builder as a version string and # nothing else, so the string is provenance only if a tag resolves it.