From 86fdb99890f18501de0ebaef67ae13425b7afba6 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 1 Oct 2026 14:37:12 +0000 Subject: [PATCH] Generalise the Debian version check into a package version check Rename testing/check-deb-version.sh to check-package-versions.sh and restructure it so the workflow extraction, the derivation run and the assertions take the workflow, job and step names as arguments. The Debian cases and wiring checks are unchanged in substance; the structure lets other packaging workflows' version derivations be checked by the same script. One change in exit semantics: a derivation step that runs cleanly but does not write a declared output is now a failed check (exit 1) rather than "could not run" (exit 2), because the harness did run and it is the workflow that is wrong. An empty version is also refused before it reaches dpkg, which would otherwise order it below everything, and a dpkg exit other than 0 or 1 is reported as "could not compare". ci.yml and ci-local.sh call the renamed script under the package-versions name. --- .github/workflows/ci.yml | 10 +- .github/workflows/package-linux.yml | 2 +- testing/check-deb-version.sh | 175 ----------------------- testing/check-package-versions.sh | 209 ++++++++++++++++++++++++++++ testing/ci-local.sh | 19 +-- 5 files changed, 225 insertions(+), 190 deletions(-) delete mode 100755 testing/check-deb-version.sh create mode 100755 testing/check-package-versions.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f02dac75..cdd51582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,11 +97,11 @@ jobs: # a matrix suite. - name: Run convergence-gate unit tests run: bash testing/lib/wait-converge-test.sh - # Runs package-linux.yml's own version derivation on a release tag, a - # candidate tag and a branch. Not a matrix suite either, so it is kept in - # step with ci-local.sh's run_deb_version by hand. - - name: Check the Debian version derived for tags and branches - run: bash testing/check-deb-version.sh + # Runs the packaging workflows' own version derivations on a release + # tag, a candidate tag and a branch. Not a matrix suite either, so it is + # kept in step with ci-local.sh's run_package_versions by hand. + - name: Check the package versions derived for tags and branches + run: bash testing/check-package-versions.sh # The unit-test jobs' flaky-test reporter, against recorded nextest # reports. Kept in step with ci-local.sh's run_nextest_flaky by hand. - name: Check the flaky-test reporter against its fixtures diff --git a/.github/workflows/package-linux.yml b/.github/workflows/package-linux.yml index 42a37d87..17fe29cf 100644 --- a/.github/workflows/package-linux.yml +++ b/.github/workflows/package-linux.yml @@ -46,7 +46,7 @@ jobs: # dpkg reads X.Y.Z-rcN as revision rcN of X.Y.Z and sorts it above # the release; X.Y.Z~rcN sorts below it. git refuses '~' in a ref # name, so the tag carries '-' and this maps it, for the .deb only. - # testing/check-deb-version.sh runs this step's text. + # testing/check-package-versions.sh runs this step's text. DEB_VERSION="$VERSION" if [[ "$GITHUB_REF" == refs/tags/* ]] \ && [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-((alpha|beta|pre|rc)[0-9]*)$ ]]; then diff --git a/testing/check-deb-version.sh b/testing/check-deb-version.sh deleted file mode 100755 index a18b747b..00000000 --- a/testing/check-deb-version.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/bin/bash -# ── Debian package version derivation check ───────────────────────────────── -# A release candidate is tagged vX.Y.Z-rcN, because git refuses '~' in a ref -# name. dpkg reads X.Y.Z-rcN as revision rcN of X.Y.Z and sorts it ABOVE the -# release, so a host that installed the candidate is never upgraded by the -# release. package-linux.yml's "Derive Linux package version" step therefore -# maps the tag's pre-release suffix to '~' for the .deb, which dpkg sorts below. -# -# This runs that step's own text, taken from the workflow, rather than a copy, -# so the check and the workflow cannot drift apart. Cases: -# refs/tags/v0.5.3 both versions 0.5.3 -# refs/tags/v0.5.3-rc1 deb 0.5.3~rc1, tarball and artifact 0.5.3-rc1, and -# dpkg orders 0.5.2 < 0.5.3~rc1 < 0.5.3 -# refs/heads/maint deb equals the tarball version, which is -# +maint.. -# -# It also checks the wiring the derivation needs to have any effect: the job -# declares the deb_package_version output, the .deb build passes it as -# --version, and that step renames a '~' in the package file name to '-' (a -# GitHub release renames an asset with special characters in its name, which -# would leave checksums-linux.txt naming a file the release does not have). -# Those three are read from the text, not executed. -# -# Exit 0 = clean. Exit 1 = a case or a wiring check failed. Exit 2 = the check -# could not run (no dpkg, no PyYAML, the step not found, or an output missing); -# never treated as a pass. -# ───────────────────────────────────────────────────────────────────────────── -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -WORKFLOW="$PROJECT_ROOT/.github/workflows/package-linux.yml" - -cant() { - echo "check-deb-version: $*; cannot verify the Debian version derivation" >&2 - exit 2 -} - -[ -f "$WORKFLOW" ] || cant "missing $WORKFLOW" -command -v dpkg >/dev/null 2>&1 || cant "dpkg not found" -command -v python3 >/dev/null 2>&1 || cant "python3 not found" -python3 -c "import yaml" >/dev/null 2>&1 || cant "python3 module 'yaml' not found" - -WORK=$(mktemp -d) || cant "mktemp failed" -trap 'rm -rf "$WORK"' EXIT - -# Pull the derivation step's run text, the job's declared outputs and the -# .deb build step's run text out of the workflow. -if ! python3 - "$WORKFLOW" "$WORK" <<'PY' -import sys -from pathlib import Path - -import yaml - -workflow, work = sys.argv[1], Path(sys.argv[2]) -doc = yaml.safe_load(Path(workflow).read_text(encoding="utf-8")) -jobs = doc.get("jobs") or {} - - -def step_run(job, name): - for step in (jobs.get(job) or {}).get("steps") or []: - if step.get("name") == name: - return step.get("run") - return None - - -derive = step_run("determine-versioning", "Derive Linux package version") -build = step_run("build", "Build Debian package in the pinned container") -if not derive or not build: - missing = "derivation" if not derive else "Debian build" - print(f"check-deb-version: the {missing} step was not found in {workflow}", - file=sys.stderr) - sys.exit(2) -(work / "derive.sh").write_text(derive, encoding="utf-8") -(work / "build.sh").write_text(build, encoding="utf-8") -outputs = (jobs.get("determine-versioning") or {}).get("outputs") or {} -(work / "outputs").write_text( - "".join(f"{k}={v}\n" for k, v in outputs.items()), encoding="utf-8") -PY -then - exit 2 -fi - -FAILED=0 -ok() { echo " PASS $*"; } -bad() { echo " FAIL $*"; FAILED=$((FAILED + 1)); } - -# Run the derivation for one ref and load its outputs into LINUX and DEB. -# Exits 2 when the step fails or does not write both outputs: no version was -# established, which is not a failed case. -derive() { - local ref="$1" out="$WORK/github_output" - : > "$out" - if ! (cd "$PROJECT_ROOT" && GITHUB_OUTPUT="$out" GITHUB_REF="$ref" \ - GITHUB_REF_NAME="${ref#refs/*/}" bash -eo pipefail "$WORK/derive.sh") >"$WORK/derive.log" 2>&1; then - echo "check-deb-version: the derivation failed for $ref:" >&2 - cat "$WORK/derive.log" >&2 - exit 2 - fi - LINUX=$(sed -n 's/^linux_package_version=//p' "$out") - DEB=$(sed -n 's/^deb_package_version=//p' "$out") - if [ -z "$LINUX" ] || [ -z "$DEB" ]; then - cant "the derivation for $ref did not write both outputs (linux '$LINUX', deb '$DEB')" - fi - return 0 -} - -# Record whether dpkg orders $1 $2 $3 (lt, gt, ...). -expect_order() { - if dpkg --compare-versions "$1" "$2" "$3"; then - ok "dpkg: $1 $2 $3" - else - bad "dpkg: $1 $2 $3 does not hold" - fi - return 0 -} - -# Record whether a derived version equals the expected one. -expect_eq() { - local label="$1" got="$2" want="$3" - if [ "$got" = "$want" ]; then - ok "$label: $got" - else - bad "$label: $got (want $want)" - fi - return 0 -} - -echo "Release tag refs/tags/v0.5.3" -derive refs/tags/v0.5.3 -expect_eq "linux_package_version" "$LINUX" "0.5.3" -expect_eq "deb_package_version" "$DEB" "0.5.3" - -echo "Candidate tag refs/tags/v0.5.3-rc1" -derive refs/tags/v0.5.3-rc1 -expect_eq "linux_package_version" "$LINUX" "0.5.3-rc1" -expect_eq "deb_package_version" "$DEB" "0.5.3~rc1" -expect_order "$DEB" lt 0.5.3 -expect_order "$DEB" gt 0.5.2 - -echo "Branch refs/heads/maint" -derive refs/heads/maint -CRATE=$(awk -F'"' '/^version = /{print $2; exit}' "$PROJECT_ROOT/Cargo.toml") -HEIGHT=$(git -C "$PROJECT_ROOT" rev-list --count HEAD) || cant "git rev-list failed" -HASH=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD) || cant "git rev-parse failed" -[ -n "$CRATE" ] || cant "no version in Cargo.toml" -expect_eq "linux_package_version" "$LINUX" "${CRATE}+maint.${HEIGHT}.${HASH}" -expect_eq "deb_package_version" "$DEB" "$LINUX" - -echo "Wiring" -# shellcheck disable=SC2016 # the ${{ }} expressions are workflow text, not shell -if grep -qxF 'deb_package_version=${{ steps.linux_version.outputs.deb_package_version }}' "$WORK/outputs"; then - ok "determine-versioning declares the deb_package_version output" -else - bad "determine-versioning does not declare deb_package_version from the derivation step" -fi -# shellcheck disable=SC2016 -if grep -qF -- '--version "${{ needs.determine-versioning.outputs.deb_package_version }}"' "$WORK/build.sh"; then - ok "the .deb build passes deb_package_version as --version" -else - bad "the .deb build does not pass deb_package_version as --version" -fi -if grep -qF "tr '~' '-'" "$WORK/build.sh"; then - ok "the .deb build renames a '~' in the package file name" -else - bad "the .deb build does not rename a '~' in the package file name" -fi - -echo -if [ "$FAILED" -eq 0 ]; then - echo "check-deb-version: all checks passed" - exit 0 -fi -echo "check-deb-version: $FAILED check(s) failed" -exit 1 diff --git a/testing/check-package-versions.sh b/testing/check-package-versions.sh new file mode 100755 index 00000000..ea069e0c --- /dev/null +++ b/testing/check-package-versions.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# ── Package version derivation check ──────────────────────────────────────── +# A release candidate is tagged vX.Y.Z-rcN, because git refuses '~' in a ref +# name. dpkg reads X.Y.Z-rcN as revision rcN of X.Y.Z and sorts it ABOVE the +# release, so a host that installed the candidate is never upgraded by the +# release. package-linux.yml's "Derive Linux package version" step therefore +# maps the tag's pre-release suffix to '~' for the .deb, which dpkg sorts below. +# +# This runs that step's own text, taken from the workflow, rather than a copy, +# so the check and the workflow cannot drift apart. +# +# Debian cases: +# refs/tags/v0.5.3 both versions 0.5.3 +# refs/tags/v0.5.3-rc1 deb 0.5.3~rc1, tarball and artifact 0.5.3-rc1, and +# dpkg orders 0.5.2 < 0.5.3~rc1 < 0.5.3 +# refs/heads/maint deb equals the tarball version, which is +# +maint.. +# +# It also checks the wiring the derivation needs to have any effect: the job +# declares the deb_package_version output, the .deb build passes it as +# --version, and that step renames a '~' in the package file name to '-' (a +# GitHub release renames an asset with special characters in its name, which +# would leave checksums-linux.txt naming a file the release does not have). +# Those three are read from the text, not executed. +# +# Exit 0 = clean. Exit 1 = a case or a wiring check failed, including a +# derivation that ran but did not write a declared output. Exit 2 = the check +# could not run (no dpkg, no PyYAML, a step not found, the derivation failed, +# or dpkg could not compare); never treated as a pass. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKFLOWS="$PROJECT_ROOT/.github/workflows" + +cant() { + echo "check-package-versions: $*; cannot verify the package version derivation" >&2 + exit 2 +} + +command -v dpkg >/dev/null 2>&1 || cant "dpkg not found" +command -v python3 >/dev/null 2>&1 || cant "python3 not found" +python3 -c "import yaml" >/dev/null 2>&1 || cant "python3 module 'yaml' not found" + +WORK=$(mktemp -d) || cant "mktemp failed" +trap 'rm -rf "$WORK"' EXIT + +# Pull a derivation step's run text, its job's declared outputs, and a build +# step's env block and run text out of a workflow, into +# $WORK/.derive.sh, .outputs and .build.sh. +# Usage: extract +extract() { + local prefix="$1" workflow="$WORKFLOWS/$2" + [ -f "$workflow" ] || cant "missing $workflow" + python3 - "$workflow" "$WORK" "$prefix" "$3" "$4" "$5" "$6" <<'PY' || exit 2 +import sys +from pathlib import Path + +import yaml + +workflow, work, prefix, job, derive_name, build_job, build_name = sys.argv[1:] +work = Path(work) +doc = yaml.safe_load(Path(workflow).read_text(encoding="utf-8")) +jobs = doc.get("jobs") or {} + + +def find_step(job, name): + for step in (jobs.get(job) or {}).get("steps") or []: + if step.get("name") == name: + return step + return None + + +derive = find_step(job, derive_name) +build = find_step(build_job, build_name) +for label, step in (("derivation", derive), ("build", build)): + if not step or not step.get("run"): + print(f"check-package-versions: the {label} step was not found in {workflow}", + file=sys.stderr) + sys.exit(2) +(work / f"{prefix}.derive.sh").write_text(derive["run"], encoding="utf-8") +env = build.get("env") or {} +(work / f"{prefix}.build.sh").write_text( + "".join(f"{k}: {v}\n" for k, v in env.items()) + build["run"], + encoding="utf-8") +outputs = (jobs.get(job) or {}).get("outputs") or {} +(work / f"{prefix}.outputs").write_text( + "".join(f"{k}={v}\n" for k, v in outputs.items()), encoding="utf-8") +PY +} + +FAILED=0 +ok() { echo " PASS $*"; } +bad() { echo " FAIL $*"; FAILED=$((FAILED + 1)); } + +# Run a derivation step for one ref and load the named outputs into OUT. +# Exits 2 when the step itself fails: no version was established. A step that +# runs but does not write a declared output is a failed check rather than a +# harness failure, so it is recorded and the output is left empty. +# Usage: derive ... +declare -A OUT +derive() { + local prefix="$1" ref="$2" out="$WORK/github_output" name + shift 2 + : > "$out" + if ! (cd "$PROJECT_ROOT" && GITHUB_OUTPUT="$out" GITHUB_REF="$ref" \ + GITHUB_REF_NAME="${ref#refs/*/}" bash -eo pipefail "$WORK/$prefix.derive.sh") >"$WORK/derive.log" 2>&1; then + echo "check-package-versions: the $prefix derivation failed for $ref:" >&2 + cat "$WORK/derive.log" >&2 + exit 2 + fi + for name in "$@"; do + OUT[$name]=$(sed -n "s/^$name=//p" "$out") + if [ -z "${OUT[$name]}" ]; then + bad "the $prefix derivation for $ref did not write $name" + fi + done + return 0 +} + +# Record whether dpkg orders $1 $2 $3 (lt, gt, ...). An empty version is a +# failed case, because dpkg would compare it and sort it below everything. +expect_order() { + local rc=0 + if [ -z "$1" ] || [ -z "$3" ]; then + bad "dpkg: no version to compare ('$1' $2 '$3')" + return 0 + fi + dpkg --compare-versions "$1" "$2" "$3" 2>"$WORK/dpkg.err" || rc=$? + case $rc in + 0) ok "dpkg: $1 $2 $3" ;; + 1) bad "dpkg: $1 $2 $3 does not hold" ;; + *) cat "$WORK/dpkg.err" >&2 + cant "dpkg could not compare $1 $2 $3 (exit $rc)" ;; + esac + return 0 +} + +# Record whether a derived version equals the expected one. +expect_eq() { + local label="$1" got="$2" want="$3" + if [ "$got" = "$want" ]; then + ok "$label: $got" + else + bad "$label: '$got' (want $want)" + fi + return 0 +} + +# Record whether extracted workflow text contains a fixed string. With -x the +# string must be a whole line. +# Usage: expect_text [-x] +expect_text() { + local flags=-qF + if [ "$1" = -x ]; then flags=-qxF; shift; fi + if grep "$flags" -- "$2" "$1"; then + ok "$3" + else + bad "not so: $3" + fi + return 0 +} + +CRATE=$(awk -F'"' '/^version = /{print $2; exit}' "$PROJECT_ROOT/Cargo.toml") +HEIGHT=$(git -C "$PROJECT_ROOT" rev-list --count HEAD) || cant "git rev-list failed" +HASH=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD) || cant "git rev-parse failed" +[ -n "$CRATE" ] || cant "no version in Cargo.toml" + +# ── Debian .deb, package-linux.yml ────────────────────────────────────────── +extract deb package-linux.yml determine-versioning "Derive Linux package version" \ + build "Build Debian package in the pinned container" + +echo "Debian: release tag refs/tags/v0.5.3" +derive deb refs/tags/v0.5.3 linux_package_version deb_package_version +expect_eq "linux_package_version" "${OUT[linux_package_version]}" "0.5.3" +expect_eq "deb_package_version" "${OUT[deb_package_version]}" "0.5.3" + +echo "Debian: candidate tag refs/tags/v0.5.3-rc1" +derive deb refs/tags/v0.5.3-rc1 linux_package_version deb_package_version +expect_eq "linux_package_version" "${OUT[linux_package_version]}" "0.5.3-rc1" +expect_eq "deb_package_version" "${OUT[deb_package_version]}" "0.5.3~rc1" +expect_order "${OUT[deb_package_version]}" lt 0.5.3 +expect_order "${OUT[deb_package_version]}" gt 0.5.2 + +echo "Debian: branch refs/heads/maint" +derive deb refs/heads/maint linux_package_version deb_package_version +expect_eq "linux_package_version" "${OUT[linux_package_version]}" "${CRATE}+maint.${HEIGHT}.${HASH}" +expect_eq "deb_package_version" "${OUT[deb_package_version]}" "${OUT[linux_package_version]}" + +echo "Debian: wiring" +# shellcheck disable=SC2016 # the ${{ }} expressions are workflow text, not shell +expect_text -x "$WORK/deb.outputs" \ + 'deb_package_version=${{ steps.linux_version.outputs.deb_package_version }}' \ + "determine-versioning declares deb_package_version from the derivation step" +# shellcheck disable=SC2016 +expect_text "$WORK/deb.build.sh" \ + '--version "${{ needs.determine-versioning.outputs.deb_package_version }}"' \ + "the .deb build passes deb_package_version as --version" +expect_text "$WORK/deb.build.sh" "tr '~' '-'" \ + "the .deb build renames a '~' in the package file name" + +echo +if [ "$FAILED" -eq 0 ]; then + echo "check-package-versions: all checks passed" + exit 0 +fi +echo "check-package-versions: $FAILED check(s) failed" +exit 1 diff --git a/testing/ci-local.sh b/testing/ci-local.sh index d6d0219a..d4526341 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -1614,15 +1614,16 @@ run_wait_converge() { record "wait-converge" $rc } -# The .deb version package-linux.yml derives for a release tag, a candidate -# tag and a branch. A candidate must sort below its release under dpkg, which -# the tag's -rcN does not, so the workflow maps it to ~rcN; this runs the -# workflow's own step text. Static, about a second, and needs nothing built. -run_deb_version() { +# The package versions the packaging workflows derive for a release tag, a +# candidate tag and a branch. A candidate must sort below its release under the +# package manager, which the tag's -rcN does not, so the workflows map it to +# ~rcN; this runs the workflows' own step text. Static, a few seconds, and +# needs nothing built. +run_package_versions() { local rc=0 - info "[deb-version] Checking the Debian version derived for tags and branches" - bash "$SCRIPT_DIR/check-deb-version.sh" || rc=$? - record "deb-version" $rc + info "[package-versions] Checking the package versions derived for tags and branches" + bash "$SCRIPT_DIR/check-package-versions.sh" || rc=$? + record "package-versions" $rc } # The GitHub unit-test jobs run check-nextest-flaky.sh after nextest to @@ -1671,7 +1672,7 @@ main() { run_portable_atomics run_shellcheck run_wait_converge - run_deb_version + run_package_versions run_nextest_flaky run_glibc_floor