Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,47 @@ 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.
#
# 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
steps:
- uses: actions/checkout@v4
with:
# Tags are the thing under test, and the default shallow checkout
# fetches none of them.
fetch-depth: 0
- 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ build/
*.egg-info/

# deb package build assests
debian/fab/
debian/keel-fab/

debian/.debhelper/
debian/debhelper-build-stamp
Expand Down
92 changes: 92 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,98 @@ 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 | 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 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.

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
Expand Down
168 changes: 168 additions & 0 deletions bin/check-release-tags
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/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: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,
# 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 <source>/<version> which is
# annotated, is an ancestor of HEAD, and whose tree carries <version> 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.
#
# 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.
#
# 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.
#
# Known limit: a version with an epoch cannot satisfy this rule, because
# refs/tags/<source>/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

PROGRAM="$(basename "$0")"

die() {
local status=$1
shift
printf '%s: %s\n' "$PROGRAM" "$*" >&2
exit "$status"
}

# changelog_entries FILE: "<source> <version> <distribution>" 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'
}

# 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; }
}

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"

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
seen_release=false

for index in "${!entries[@]}"; do
read -r source version distribution <<<"${entries[$index]}"

[[ "$distribution" == UNRELEASED ]] && continue
if [[ -n "$since" ]] && ! dpkg --compare-versions "$version" ge "$since"
then
continue
fi

seen_release=true
tag="$source/$version"

# 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"
fi
continue
fi

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

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

$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"
32 changes: 32 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
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-*
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 <admin@keellinux.org> 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
Expand Down
Loading
Loading