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