diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86b6707..2aebd16 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 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/COVERAGE.md b/COVERAGE.md index 9b238a9..c4967ca 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -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 diff --git a/bin/check-release-tags b/bin/check-release-tags new file mode 100755 index 0000000..f7086df --- /dev/null +++ b/bin/check-release-tags @@ -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 / 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. +# +# 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//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: " " 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" diff --git a/debian/changelog b/debian/changelog index b572d56..0c1110b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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/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/debian/rules b/debian/rules index adf0870..50ba996 100755 --- a/debian/rules +++ b/debian/rules @@ -2,5 +2,41 @@ 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) +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/$(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 +# 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/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..06c9090 --- /dev/null +++ b/fablib/version.py @@ -0,0 +1,41 @@ +"""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. 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 + +VERSION_PATH = "/usr/share/fab/version" +UNKNOWN = "unknown" + + +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 = 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/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 diff --git a/tests/packaging.sh b/tests/packaging.sh new file mode 100755 index 0000000..60bf24f --- /dev/null +++ b/tests/packaging.sh @@ -0,0 +1,270 @@ +#!/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 + +# 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 + +# 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" \ + "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 + +# 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. +recorded() { + FAB_VERSION_FILE="$1" python3 -c \ + 'from fablib.version import package_version; print(package_version())' \ + 2>&1 +} + +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/version")" "0.1.0" + +printf ' 2.3.4 \n' > "$work/share/version" +is "surrounding whitespace is not part of the version" \ + "$(recorded "$work/share/version")" "2.3.4" + +is "an absent version file reports unknown, not a wrong answer" \ + "$(recorded "$work/nothing-here")" "unknown" + +: > "$work/share/version" +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 new file mode 100755 index 0000000..f0716c9 --- /dev/null +++ b/tests/release-tags.sh @@ -0,0 +1,299 @@ +#!/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: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 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. +# +# 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: 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 ARG...: run the script, leaving its output in $out and its 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 + +# --- 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)" +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 + +# --- a lightweight tag is not a release --------------------------------------- + +# 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 + +run "$repo" +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 -qi 'not in this branch' <<<"$out"; then + ok "the failure says the tag is not in this branch" +else + not_ok "the failure says the tag is not in this branch" "$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 2.0.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 + +# --- 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)" +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" + +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)" +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" 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() {