From 507c31d8d786ea8ade176e513f0f53f5c3f2bdd7 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 20:23:26 -0230 Subject: [PATCH 1/8] patches: lift the four-digit series ceiling at 0099 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series globs matched 00*.patch, so a patch numbered 0100 or above was applied by nothing and audited by nothing — no error, just a runtime missing the fix. The Wine series is at 0097. Match [0-9][0-9][0-9][0-9]-*.patch instead, in the apply loop, the stack stamp and the freeze. Zero-padded four-digit names sort the same lexically as numerically, so apply order is unchanged. The unlisted-patch check now lists every .patch on disk rather than the ones the series glob matches, so a file that falls outside the numbering scheme is reported instead of passing as absent. --- scripts/build-audit.sh | 7 +++++-- scripts/container-build.sh | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index 58ea833d..cecc005a 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -43,7 +43,7 @@ fi # --- --freeze: (re)generate the frozen series manifest ------------------------ if [ "${1:-}" = --freeze ]; then - new="$(cd "$root/patches" && sha256sum 00*.patch pipeasio/*.patch)" + new="$(cd "$root/patches" && sha256sum [0-9][0-9][0-9][0-9]-*.patch pipeasio/*.patch)" check_required_series_tails <(printf '%s\n' "$new") if [ -f "$SERIES" ]; then say "== freeze diff (old -> new) ==" @@ -97,7 +97,10 @@ while read -r sum file; do sha_ok["$file"]=0 fi done < "$SERIES" -extras="$(cd "$root/patches" && printf '%s\n' 00*.patch pipeasio/*.patch \ +# Every .patch on disk, not just the numbered ones the series globs match: a file +# the series glob skips is applied by nothing and audited by nothing, so it has +# to surface here rather than pass as absent. +extras="$(cd "$root/patches" && printf '%s\n' *.patch pipeasio/*.patch \ | grep -vxF -f <(awk '{print $2}' "$SERIES") || true)" [ -z "$extras" ] && ok "no unlisted patches" "" || bad "unlisted patches present" "$extras" # Retired numbers stay retired (renumbering would break cross-references in patch diff --git a/scripts/container-build.sh b/scripts/container-build.sh index e174c728..4ca1650f 100755 --- a/scripts/container-build.sh +++ b/scripts/container-build.sh @@ -31,7 +31,7 @@ ABLETON_LINKD_SHA="${ABLETON_LINKD_SHA:-}" } DESTDIR="$WORK/stage" PREFIX_ROOT="$DESTDIR$CONFIGURE_PREFIX" -npatch="$(ls "$SRC"/patches/00*.patch | wc -l)" +npatch="$(ls "$SRC"/patches/[0-9][0-9][0-9][0-9]-*.patch | wc -l)" # TSan reserves a fixed shadow address range. High-entropy ASLR can collide # with that range, and newer runtimes may be unable to request process-local @@ -143,7 +143,7 @@ git -c user.email=build@localhost -c user.name=dist commit -q -m "base 5c23dd1c" # The series ships without From:/Date: mail headers; git am refuses to commit # with an empty author, so supply a fixed neutral ident (fixed date keeps the # apply reproducible). Patches that still carry headers keep their own. -for p in "$SRC"/patches/00*.patch; do +for p in "$SRC"/patches/[0-9][0-9][0-9][0-9]-*.patch; do if head -8 "$p" | grep -q '^From: '; then git -c user.email=build@localhost -c user.name=dist am --3way "$p" else @@ -629,7 +629,7 @@ LC_ALL=C sort -c -u "$builder_packages" builder_packages_sha="$(sha256sum "$builder_packages" | awk '{print $1}')" # Stamp per-patch sha256s into the tree; build-audit.sh diffs this against patches/SERIES.sha256. stack_stamp="$PREFIX_ROOT/ABLETON-WINE-PATCH-STACK.txt" -( cd "$SRC/patches" && sha256sum 00*.patch pipeasio/*.patch ) > "$stack_stamp" +( cd "$SRC/patches" && sha256sum [0-9][0-9][0-9][0-9]-*.patch pipeasio/*.patch ) > "$stack_stamp" stack_sha="$(sha256sum "$stack_stamp" | awk '{print $1}')" build_info="$PREFIX_ROOT/ABLETON-WINE-BUILD-INFO.txt" { From 8e73d6476f96711a24da335dcda614dc75eca484 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:15:21 -0230 Subject: [PATCH 2/8] build-audit: let --freeze regenerate the series manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_required_series_tails() reads its manifest argument twice, and --freeze passed it a process substitution. On Linux /dev/fd/N is the pipe itself, so the first awk drained it and the PipeASIO tail always resolved to "none" — every --freeze run failed before writing, leaving no working way to regenerate patches/SERIES.sha256. --check-series-policy was unaffected because it takes a real file. Read the manifest once so a FIFO argument survives, and drop the policy call from the --freeze path: --freeze records the series as it stands, and a new terminal patch is the change it exists to record. The policy still runs on the committed manifest, which is where every automated caller enters. --- scripts/build-audit.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index cecc005a..117385c2 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -15,10 +15,12 @@ readonly REQUIRED_PIPEASIO_TAIL='pipeasio/0011-controlpanel-dialog-off-the-host- check_required_series_tails() { - local manifest="${1:?series manifest required}" wine_tail pipeasio_tail + local manifest="${1:?series manifest required}" body wine_tail pipeasio_tail [ -r "$manifest" ] || fail "series manifest is missing or unreadable: $manifest" - wine_tail="$(awk '$2 !~ /^pipeasio\// { print $2 }' "$manifest" | sort | tail -1)" - pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' "$manifest" | sort | tail -1)" + # Read once: the manifest path may be a FIFO, which the first reader drains. + body="$(cat -- "$manifest")" + wine_tail="$(awk '$2 !~ /^pipeasio\// { print $2 }' <<<"$body" | sort | tail -1)" + pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' <<<"$body" | sort | tail -1)" [ "$wine_tail" = "$REQUIRED_WINE_TAIL" ] || fail "Wine series must end at $REQUIRED_WINE_TAIL (found ${wine_tail:-none})" [ "$pipeasio_tail" = "$REQUIRED_PIPEASIO_TAIL" ] || @@ -42,9 +44,12 @@ if [ "${1:-}" = --source-tree-sha ]; then fi # --- --freeze: (re)generate the frozen series manifest ------------------------ +# The tail policy is not applied here. --freeze records the series as it stands, +# and a new terminal patch is the change it exists to record; gating generation +# on the previous tail makes that change unrecordable. Enforcement is on the +# committed manifest below and on every path that audits an artifact. if [ "${1:-}" = --freeze ]; then new="$(cd "$root/patches" && sha256sum [0-9][0-9][0-9][0-9]-*.patch pipeasio/*.patch)" - check_required_series_tails <(printf '%s\n' "$new") if [ -f "$SERIES" ]; then say "== freeze diff (old -> new) ==" diff -u "$SERIES" <(printf '%s\n' "$new") && say " (no changes)" From 6ebb7778dc7069e93d2aa823902574446cb0720e Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:15:21 -0230 Subject: [PATCH 3/8] tests: cover the --freeze manifest path make test exercised the tail policy only through --check-series-policy with real files, so the suite stayed green while --freeze could not run at all. Freeze against a copy of patches/ and compare the result with the committed manifest, then confirm a drifted series freezes but fails the audit path. --- scripts/test-release-policy.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index a1e62c3d..48678c9c 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -287,3 +287,26 @@ if bash "$series_checker" --check-series-policy "$tmp/no-pipeasio-series" >/dev/ fail 'series policy accepted an empty PipeASIO series' fi printf 'ok - patch series cannot lose either required terminal member\n' + +# --freeze writes patches/SERIES.sha256 next to the script it runs from, so it is +# exercised against a copy of patches/ and never rewrites the repo's manifest. +mkdir -p "$tmp/freeze/scripts" +cp -a "$root/patches" "$tmp/freeze/patches" +cp -a "$series_checker" "$tmp/freeze/scripts/build-audit.sh" +freeze_checker="$tmp/freeze/scripts/build-audit.sh" +bash "$freeze_checker" --freeze >/dev/null \ + || fail '--freeze cannot regenerate the series manifest' +cmp -s "$tmp/freeze/patches/SERIES.sha256" "$series" \ + || fail '--freeze regenerated a manifest that differs from the committed one' +printf 'ok - --freeze regenerates the committed manifest\n' + +# Recording a series change and enforcing the tail policy are separate steps: a +# freeze reports whatever is on disk, and the audit path is what rejects it. +rm -f "$tmp/freeze/patches/$wine_tail" +bash "$freeze_checker" --freeze >/dev/null \ + || fail '--freeze refused to record a series whose terminal Wine patch changed' +if bash "$series_checker" --check-series-policy "$tmp/freeze/patches/SERIES.sha256" \ + >/dev/null 2>&1; then + fail 'series policy accepted a frozen manifest missing its terminal Wine patch' +fi +printf 'ok - --freeze records series drift and the audit path rejects it\n' From c1000365f1ce0f741499e0494c14c6a43f56b733 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:49:49 -0230 Subject: [PATCH 4/8] build-audit: replace the series tail pins with a removal check REQUIRED_WINE_TAIL and REQUIRED_PIPEASIO_TAIL named whichever patch currently ended each series, so every patch added to the end required editing them. Over the last 25 revisions of patches/SERIES.sha256 the Wine tail took 10 distinct values, and the pinned patch alone changed name three times and content four. A constant tracking a patch under active development cannot hold. Check the transition instead: compare an old manifest with a new one and report only entries that are in neither by name, by sha256, nor by the name without its NNNN- prefix. An edit keeps the name, a renumber keeps the sha256 (patch files do not embed their own number), and both at once keep the suffix, so an entry matching none of the three is no longer in the series. Every such entry needs a gap-table entry or --allow-series-removals REASON. Both gap tables move above the --freeze block so it can consult them. Over the same 25 revisions this reports three, of which two are patches genuinely gone from the series; the third renamed a patch in place under the same number, which breaks all three links at once. The audit path no longer runs a series-content check of its own: the numbering and gap checks in [1/4] already cover the committed manifest. --- scripts/build-audit.sh | 104 ++++++++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index 117385c2..8abe3f28 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -10,27 +10,74 @@ SERIES="$root/patches/SERIES.sha256" say() { printf '%s\n' "$*"; } fail() { printf '!! %s\n' "$*" >&2; exit 1; } -readonly REQUIRED_WINE_TAIL='0097-winex11-restore-pointer-inertia-and-ignore-held-scroll.patch' -readonly REQUIRED_PIPEASIO_TAIL='pipeasio/0011-controlpanel-dialog-off-the-host-gui-thread.patch' +# Retired numbers stay retired (renumbering would break cross-references in patch +# titles and release history); a gap is fine if documented here, a dropped patch is not. +# The two series are numbered independently, so each has its own table. +declare -A SERIES_GAPS=( + [0027]="retired 2026-07-14 — gitignore housekeeping, no artifact effect" + [0044]="reserved 2026-07-24 for the issue 57 parked-pane reblit gate; shipped as 0056 instead" +) +declare -A PIPEASIO_GAPS=( + [0003]="warning-text fix superseded by the 1.5.0 diagnostic relay; the corrected text lives in 0005's quantum diagnostic (both arbitration and converge wordings)" + [0007]="follower headroom retired 2026-08-10, mechanism ineffective mid-stream (a live api.alsa.headroom write lands in default_headroom only and takes effect on the next renegotiation, not the running stream)" +) + +# Match each entry of an old manifest to the new one by name, by sha256, or by the +# name without its NNNN- prefix: an edit keeps the name, a renumber keeps the +# sha256, and both together keep only that suffix. An entry matching none of the +# three is no longer in the series. Prints those entries, one per line. +# awk reads each manifest once, so a process substitution is a valid argument. +series_removals() # $1: old manifest, $2: new manifest +{ + awk ' + function suffix(n) { sub(/^[0-9]{4}-/, "", n); return n } + NR==FNR { by_hash[$1]; by_name[$2]; by_suffix[suffix($2)]; next } + { + if ($2 in by_name) next + if ($1 in by_hash) next + if (suffix($2) in by_suffix) next + print $2 + } + ' "$2" "$1" +} + +series_gap_reason() # manifest entry -> its documented gap reason, or nothing +{ + local base="$1" + case "$1" in + pipeasio/*) base="${1#pipeasio/}"; printf '%s' "${PIPEASIO_GAPS[${base%%-*}]:-}" ;; + *) printf '%s' "${SERIES_GAPS[${base%%-*}]:-}" ;; + esac +} -check_required_series_tails() +# A merge that drops a patch and an intentional retirement produce the same +# manifest, so every removal needs a SERIES_GAPS entry or an explicit reason. +require_explained_removals() # $1: old manifest, $2: new manifest, $3: reason, if any { - local manifest="${1:?series manifest required}" body wine_tail pipeasio_tail - [ -r "$manifest" ] || fail "series manifest is missing or unreadable: $manifest" - # Read once: the manifest path may be a FIFO, which the first reader drains. - body="$(cat -- "$manifest")" - wine_tail="$(awk '$2 !~ /^pipeasio\// { print $2 }' <<<"$body" | sort | tail -1)" - pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' <<<"$body" | sort | tail -1)" - [ "$wine_tail" = "$REQUIRED_WINE_TAIL" ] || - fail "Wine series must end at $REQUIRED_WINE_TAIL (found ${wine_tail:-none})" - [ "$pipeasio_tail" = "$REQUIRED_PIPEASIO_TAIL" ] || - fail "PipeASIO series must end at $REQUIRED_PIPEASIO_TAIL (found ${pipeasio_tail:-none})" + local reason="${3:-}" entry documented unexplained="" + for entry in "$1" "$2"; do + [ -r "$entry" ] || fail "series manifest is missing or unreadable: $entry" + done + while read -r entry; do + [ -n "$entry" ] || continue + documented="$(series_gap_reason "$entry")" + if [ -n "$documented" ]; then + say " removed $entry ($documented)" + else + unexplained="$unexplained${unexplained:+ }$entry" + fi + done < <(series_removals "$1" "$2") + [ -n "$unexplained" ] || return 0 + [ -n "$reason" ] || fail "no longer in the series and undocumented: $unexplained"$'\n'\ +" add a SERIES_GAPS entry, or rerun with --allow-series-removals REASON" + say " removals allowed ($reason):" + for entry in $unexplained; do say " $entry"; done } -if [ "${1:-}" = --check-series-policy ]; then - [ "$#" -eq 2 ] || fail "usage: $0 --check-series-policy MANIFEST" - check_required_series_tails "$2" - say "OK: required Wine and PipeASIO series tails are present." +if [ "${1:-}" = --check-series-removals ]; then + [ "$#" -eq 3 ] || fail "usage: $0 --check-series-removals OLD_MANIFEST NEW_MANIFEST" + require_explained_removals "$2" "$3" + say "OK: every patch removed from the series is documented." exit 0 fi @@ -44,15 +91,19 @@ if [ "${1:-}" = --source-tree-sha ]; then fi # --- --freeze: (re)generate the frozen series manifest ------------------------ -# The tail policy is not applied here. --freeze records the series as it stands, -# and a new terminal patch is the change it exists to record; gating generation -# on the previous tail makes that change unrecordable. Enforcement is on the -# committed manifest below and on every path that audits an artifact. +# --freeze records whatever patches/ contains. Additions are not checked, so a +# new patch needs no edit here; removals are, per require_explained_removals. if [ "${1:-}" = --freeze ]; then + allow_removals="" + if [ "${2:-}" = --allow-series-removals ]; then + [ -n "${3:-}" ] || fail "usage: $0 --freeze [--allow-series-removals REASON]" + allow_removals="$3" + fi new="$(cd "$root/patches" && sha256sum [0-9][0-9][0-9][0-9]-*.patch pipeasio/*.patch)" if [ -f "$SERIES" ]; then say "== freeze diff (old -> new) ==" diff -u "$SERIES" <(printf '%s\n' "$new") && say " (no changes)" + require_explained_removals "$SERIES" <(printf '%s\n' "$new") "$allow_removals" else say "== creating $SERIES ==" fi @@ -62,7 +113,6 @@ if [ "${1:-}" = --freeze ]; then fi [ -f "$SERIES" ] || fail "patches/SERIES.sha256 missing — run: ./scripts/build-audit.sh --freeze (then commit it)" -check_required_series_tails "$SERIES" grep -qP 'x' <<<'x' 2>/dev/null || fail "grep -P not supported on this system (needed for UTF-16 fingerprints)" # --- resolve the artifact: tarball (unpack to tmp) or tree -------------------- @@ -108,12 +158,6 @@ done < "$SERIES" extras="$(cd "$root/patches" && printf '%s\n' *.patch pipeasio/*.patch \ | grep -vxF -f <(awk '{print $2}' "$SERIES") || true)" [ -z "$extras" ] && ok "no unlisted patches" "" || bad "unlisted patches present" "$extras" -# Retired numbers stay retired (renumbering would break cross-references in patch -# titles and release history); a gap is fine if documented here, a dropped patch is not. -declare -A SERIES_GAPS=( - [0027]="retired 2026-07-14 — gitignore housekeeping, no artifact effect" - [0044]="reserved 2026-07-24 for the issue 57 parked-pane reblit gate; shipped as 0056 instead" -) seq_expect=1 for f in $(awk '{print $2}' "$SERIES" | grep -v '^pipeasio/' | sort); do num="${f%%-*}" @@ -132,10 +176,6 @@ done # The pipeasio series is numbered independently of the Wine one, and the loop # above skips it. Check it the same way, or a dropped or misnumbered patch # passes with only its checksum looked at. -declare -A PIPEASIO_GAPS=( - [0003]="warning-text fix superseded by the 1.5.0 diagnostic relay; the corrected text lives in 0005's quantum diagnostic (both arbitration and converge wordings)" - [0007]="follower headroom retired 2026-08-10, mechanism ineffective mid-stream (a live api.alsa.headroom write lands in default_headroom only and takes effect on the next renegotiation, not the running stream)" -) asio_expect=1 for f in $(awk '{print $2}' "$SERIES" | grep '^pipeasio/' | sort); do base="${f#pipeasio/}" From 44e3ea9e2bad0d9fa8ea3035c10ed01437435f71 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:49:49 -0230 Subject: [PATCH 5/8] ci: check the patch series against the PR base --freeze reports removals only to whoever runs it, and the manifest can be written by hand. Compare the base commit's manifest with the head's on every pull request. Needs the full history: the default shallow clone does not contain the base commit. --- .github/workflows/ci-pr-build.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci-pr-build.yml b/.github/workflows/ci-pr-build.yml index 47f685f7..8750ceee 100644 --- a/.github/workflows/ci-pr-build.yml +++ b/.github/workflows/ci-pr-build.yml @@ -65,6 +65,19 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v7 + with: + # The series-removal check reads the base commit's copy of + # patches/SERIES.sha256, which a shallow clone does not contain. + fetch-depth: 0 + + - name: Check the patch series for undocumented removals + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git show "$BASE_SHA:patches/SERIES.sha256" > "$RUNNER_TEMP/base-series.sha256" + ./scripts/build-audit.sh --check-series-removals \ + "$RUNNER_TEMP/base-series.sha256" patches/SERIES.sha256 - name: Read runtime version id: runtime_version From 0970f6f499a86be82892e06b8333b13c0fb60c46 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:49:49 -0230 Subject: [PATCH 6/8] tests: cover the series-removal classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One case per link the classifier matches on — added, edited, renumbered, renumbered and edited at once — plus a dropped patch, an emptied PipeASIO series, and a documented gap number from each table. The --freeze cases check that a renumber records without comment and a deletion stops without a reason. --- scripts/test-release-policy.sh | 78 ++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index 48678c9c..fe2f2788 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -261,32 +261,53 @@ printf 'ok - installer packing, tagging, and release drafting share the gate\n' series_checker="$root/scripts/build-audit.sh" series="$root/patches/SERIES.sha256" -bash "$series_checker" --check-series-policy "$series" >/dev/null \ - || fail 'complete patch series failed its terminal-member policy' +removals() { bash "$series_checker" --check-series-removals "$1" "$2" >/dev/null 2>&1; } -# Read the terminal members off the manifest rather than naming them: the -# policy pins whichever patch currently ends each series, so a hardcoded name -# turns into a failing negative test the next time either series grows. wine_tail="$(awk '$2 !~ /^pipeasio\// { print $2 }' "$series" | sort | tail -1)" -pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' "$series" | sort | tail -1)" +wine_tail_hash="$(awk -v f="$wine_tail" '$2 == f { print $1 }' "$series")" +wine_tail_suffix="${wine_tail#[0-9][0-9][0-9][0-9]-}" -grep -v " $wine_tail\$" \ - "$series" > "$tmp/no-wine-tail" -if bash "$series_checker" --check-series-policy "$tmp/no-wine-tail" >/dev/null 2>&1; then - fail 'series policy accepted a missing final Wine patch' -fi +removals "$series" "$series" \ + || fail 'series policy reported a removal between a manifest and itself' + +# An added patch is not a removal, and each way of altering an existing entry +# leaves one of the three links back to it: name, sha256, or the suffix. +{ cat "$series"; echo "$wine_tail_hash 0999-a-patch-that-was-added.patch"; } > "$tmp/added" +removals "$series" "$tmp/added" || fail 'series policy rejected an added patch' + +sed "s|^$wine_tail_hash |0000000000000000000000000000000000000000000000000000000000000000 |" \ + "$series" > "$tmp/edited" +removals "$series" "$tmp/edited" || fail 'series policy rejected an edited patch' + +sed "s| $wine_tail\$| 0098-$wine_tail_suffix|" "$series" > "$tmp/renumbered" +removals "$series" "$tmp/renumbered" || fail 'series policy rejected a renumbered patch' + +sed -e "s|^$wine_tail_hash |0000000000000000000000000000000000000000000000000000000000000000 |" \ + -e "s| $wine_tail\$| 0098-$wine_tail_suffix|" "$series" > "$tmp/renumbered-edited" +removals "$series" "$tmp/renumbered-edited" \ + || fail 'series policy rejected a patch renumbered and edited at once' -grep -v " $pipeasio_tail\$" \ - "$series" > "$tmp/no-pipeasio-tail" -if bash "$series_checker" --check-series-policy "$tmp/no-pipeasio-tail" >/dev/null 2>&1; then - fail 'series policy accepted a missing final PipeASIO patch' +grep -v " $wine_tail\$" "$series" > "$tmp/dropped" +if removals "$series" "$tmp/dropped"; then + fail 'series policy accepted a patch that left the series' fi grep -v ' pipeasio/' "$series" > "$tmp/no-pipeasio-series" -if bash "$series_checker" --check-series-policy "$tmp/no-pipeasio-series" >/dev/null 2>&1; then - fail 'series policy accepted an empty PipeASIO series' +if removals "$series" "$tmp/no-pipeasio-series"; then + fail 'series policy accepted an emptied PipeASIO series' fi -printf 'ok - patch series cannot lose either required terminal member\n' + +# Each series has its own gap table: 0027 is documented in one, pipeasio/0003 +# in the other, so removing either needs no further reason. +{ echo "$wine_tail_hash 0027-a-retired-patch.patch"; cat "$series"; } > "$tmp/with-gap" +removals "$tmp/with-gap" "$series" \ + || fail 'series policy rejected the removal of a documented gap number' + +{ echo "$wine_tail_hash pipeasio/0003-a-retired-patch.patch"; cat "$series"; } \ + > "$tmp/with-pipeasio-gap" +removals "$tmp/with-pipeasio-gap" "$series" \ + || fail 'series policy rejected the removal of a documented PipeASIO gap number' +printf 'ok - patch series cannot lose a patch without a documented reason\n' # --freeze writes patches/SERIES.sha256 next to the script it runs from, so it is # exercised against a copy of patches/ and never rewrites the repo's manifest. @@ -300,13 +321,18 @@ cmp -s "$tmp/freeze/patches/SERIES.sha256" "$series" \ || fail '--freeze regenerated a manifest that differs from the committed one' printf 'ok - --freeze regenerates the committed manifest\n' -# Recording a series change and enforcing the tail policy are separate steps: a -# freeze reports whatever is on disk, and the audit path is what rejects it. -rm -f "$tmp/freeze/patches/$wine_tail" +# A renumbered patch freezes without comment; a deleted one needs the reason flag. +mv "$tmp/freeze/patches/$wine_tail" "$tmp/freeze/patches/0098-$wine_tail_suffix" bash "$freeze_checker" --freeze >/dev/null \ - || fail '--freeze refused to record a series whose terminal Wine patch changed' -if bash "$series_checker" --check-series-policy "$tmp/freeze/patches/SERIES.sha256" \ - >/dev/null 2>&1; then - fail 'series policy accepted a frozen manifest missing its terminal Wine patch' + || fail '--freeze refused to record a renumbered patch' + +rm -f "$tmp/freeze/patches/0098-$wine_tail_suffix" +if bash "$freeze_checker" --freeze >/dev/null 2>&1; then + fail '--freeze recorded a deleted patch without a reason' +fi +bash "$freeze_checker" --freeze --allow-series-removals 'test fixture' >/dev/null \ + || fail '--allow-series-removals did not permit a documented removal' +if grep -qF " $wine_tail" "$tmp/freeze/patches/SERIES.sha256"; then + fail '--freeze kept a deleted patch in the manifest' fi -printf 'ok - --freeze records series drift and the audit path rejects it\n' +printf 'ok - --freeze records a renumber and stops on an undocumented deletion\n' From f6c87240e844463d076f4f58babe4bfee2a794df Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 19:59:22 -0230 Subject: [PATCH 7/8] build-audit: close three fail-open paths in the removal check A pipeasio/NNNN- entry does not start with its number, so the suffix match never applied to the PipeASIO series and a patch renumbered and edited there reported as a removal. Take the suffix from the basename, keeping the directory in the key so the two series cannot match each other. NR==FNR reads the second file into the first file's tables when the first is empty, so an empty or truncated new manifest reported no removals at all. Key on FILENAME instead. A read error in the awk (a directory, an unreadable path that passes -r) was consumed by the while loop as an empty removal list and passed. Assign the output so the status propagates, and print one entry per line rather than one long line when several patches are removed. --- .github/workflows/ci-pr-build.yml | 8 +++---- scripts/build-audit.sh | 38 +++++++++++++++++++++++-------- scripts/test-release-policy.sh | 20 ++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci-pr-build.yml b/.github/workflows/ci-pr-build.yml index 8750ceee..522b1390 100644 --- a/.github/workflows/ci-pr-build.yml +++ b/.github/workflows/ci-pr-build.yml @@ -65,16 +65,16 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v7 - with: - # The series-removal check reads the base commit's copy of - # patches/SERIES.sha256, which a shallow clone does not contain. - fetch-depth: 0 - name: Check the patch series for undocumented removals if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + # One commit, not the whole history: the checkout above is shallow and + # only the base copy of patches/SERIES.sha256 is needed here. + git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null \ + || git fetch --depth=1 origin "$BASE_SHA" git show "$BASE_SHA:patches/SERIES.sha256" > "$RUNNER_TEMP/base-series.sha256" ./scripts/build-audit.sh --check-series-removals \ "$RUNNER_TEMP/base-series.sha256" patches/SERIES.sha256 diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index 8abe3f28..469df8f9 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -29,9 +29,20 @@ declare -A PIPEASIO_GAPS=( # awk reads each manifest once, so a process substitution is a valid argument. series_removals() # $1: old manifest, $2: new manifest { - awk ' - function suffix(n) { sub(/^[0-9]{4}-/, "", n); return n } - NR==FNR { by_hash[$1]; by_name[$2]; by_suffix[suffix($2)]; next } + # Keyed on FILENAME rather than NR==FNR, which reads the second file into the + # first file's tables when the first is empty and so reports no removals at all. + NEW_MANIFEST="$2" awk ' + # The number is not at the start of a pipeasio/NNNN- entry. Keep the + # directory in the key so the two series cannot match each other. + function suffix(n, cut, tail) { + cut = index(n, "/") + tail = substr(n, cut + 1) + sub(/^[0-9]{4}-/, "", tail) + return substr(n, 1, cut) tail + } + FILENAME == ENVIRON["NEW_MANIFEST"] { + by_hash[$1]; by_name[$2]; by_suffix[suffix($2)]; next + } { if ($2 in by_name) next if ($1 in by_hash) next @@ -54,24 +65,31 @@ series_gap_reason() # manifest entry -> its documented gap reason, or nothing # manifest, so every removal needs a SERIES_GAPS entry or an explicit reason. require_explained_removals() # $1: old manifest, $2: new manifest, $3: reason, if any { - local reason="${3:-}" entry documented unexplained="" + local reason="${3:-}" entry documented removed + local -a unexplained=() for entry in "$1" "$2"; do [ -r "$entry" ] || fail "series manifest is missing or unreadable: $entry" done + # Assigned, not piped: a read error in series_removals must stop the run + # rather than read as an empty removal list. + removed="$(series_removals "$1" "$2")" || fail "cannot read the series manifests" while read -r entry; do [ -n "$entry" ] || continue documented="$(series_gap_reason "$entry")" if [ -n "$documented" ]; then say " removed $entry ($documented)" else - unexplained="$unexplained${unexplained:+ }$entry" + unexplained+=("$entry") fi - done < <(series_removals "$1" "$2") - [ -n "$unexplained" ] || return 0 - [ -n "$reason" ] || fail "no longer in the series and undocumented: $unexplained"$'\n'\ -" add a SERIES_GAPS entry, or rerun with --allow-series-removals REASON" + done <<< "$removed" + [ "${#unexplained[@]}" -gt 0 ] || return 0 + if [ -z "$reason" ]; then + printf '!! no longer in the series and undocumented:\n' >&2 + printf ' %s\n' "${unexplained[@]}" >&2 + fail "add a gap-table entry for each, or rerun with --allow-series-removals REASON" + fi say " removals allowed ($reason):" - for entry in $unexplained; do say " $entry"; done + printf ' %s\n' "${unexplained[@]}" } if [ "${1:-}" = --check-series-removals ]; then diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index fe2f2788..4051abc5 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -287,11 +287,31 @@ sed -e "s|^$wine_tail_hash |0000000000000000000000000000000000000000000000000000 removals "$series" "$tmp/renumbered-edited" \ || fail 'series policy rejected a patch renumbered and edited at once' +# The number sits after the directory in a pipeasio/ entry, so the suffix has to +# be taken from the basename or this series loses the renumbered-and-edited case. +pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' "$series" | sort | tail -1)" +pipeasio_tail_hash="$(awk -v f="$pipeasio_tail" '$2 == f { print $1 }' "$series")" +sed -e "s|^$pipeasio_tail_hash |0000000000000000000000000000000000000000000000000000000000000000 |" \ + -e "s| $pipeasio_tail\$| pipeasio/0099-${pipeasio_tail#pipeasio/[0-9][0-9][0-9][0-9]-}|" \ + "$series" > "$tmp/pipeasio-renumbered-edited" +removals "$series" "$tmp/pipeasio-renumbered-edited" \ + || fail 'series policy rejected a PipeASIO patch renumbered and edited at once' + grep -v " $wine_tail\$" "$series" > "$tmp/dropped" if removals "$series" "$tmp/dropped"; then fail 'series policy accepted a patch that left the series' fi +# An empty new manifest must report every patch, and an unreadable one must stop +# the run — both are cases where a naive reader reports nothing and passes. +: > "$tmp/empty-manifest" +if removals "$series" "$tmp/empty-manifest"; then + fail 'series policy accepted a manifest that lost every patch' +fi +if removals "$series" "$tmp"; then + fail 'series policy passed on a manifest it could not read' +fi + grep -v ' pipeasio/' "$series" > "$tmp/no-pipeasio-series" if removals "$series" "$tmp/no-pipeasio-series"; then fail 'series policy accepted an emptied PipeASIO series' From 8f8df6bdd7922a76ac0d6cfcc412f7dc0c36d961 Mon Sep 17 00:00:00 2001 From: Lucas Gillingham Date: Thu, 13 Aug 2026 20:04:28 -0230 Subject: [PATCH 8/8] build-audit: drop the redundant name match, fix two hollow tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equal names have equal suffixes, so the name comparison could never be the only match — removing it changed no verdict across the last 25 revisions of patches/SERIES.sha256. Two links remain, and each is now the sole match in one test: sha256 for a renamed patch, suffix for a renumbered and edited one. The gap-table fixtures reused a surviving patch's sha256, so both entries matched on hash and never reached the tables they were meant to exercise. Give them a hash and a description of their own. --- scripts/build-audit.sh | 13 +++++++------ scripts/test-release-policy.sh | 13 +++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index 469df8f9..16d6ec27 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -22,10 +22,12 @@ declare -A PIPEASIO_GAPS=( [0007]="follower headroom retired 2026-08-10, mechanism ineffective mid-stream (a live api.alsa.headroom write lands in default_headroom only and takes effect on the next renegotiation, not the running stream)" ) -# Match each entry of an old manifest to the new one by name, by sha256, or by the -# name without its NNNN- prefix: an edit keeps the name, a renumber keeps the -# sha256, and both together keep only that suffix. An entry matching none of the -# three is no longer in the series. Prints those entries, one per line. +# Match each entry of an old manifest to the new one by sha256 or by the name +# without its NNNN- prefix. Editing a patch keeps that suffix, renumbering keeps +# the sha256 (patch files do not carry their own number), and doing both keeps +# the suffix. An entry matching neither is no longer in the series. Prints those +# entries, one per line. Matching on the full name as well would add nothing: +# equal names have equal suffixes. # awk reads each manifest once, so a process substitution is a valid argument. series_removals() # $1: old manifest, $2: new manifest { @@ -41,10 +43,9 @@ series_removals() # $1: old manifest, $2: new manifest return substr(n, 1, cut) tail } FILENAME == ENVIRON["NEW_MANIFEST"] { - by_hash[$1]; by_name[$2]; by_suffix[suffix($2)]; next + by_hash[$1]; by_suffix[suffix($2)]; next } { - if ($2 in by_name) next if ($1 in by_hash) next if (suffix($2) in by_suffix) next print $2 diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index 4051abc5..6b6a1fba 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -287,6 +287,12 @@ sed -e "s|^$wine_tail_hash |0000000000000000000000000000000000000000000000000000 removals "$series" "$tmp/renumbered-edited" \ || fail 'series policy rejected a patch renumbered and edited at once' +# Renaming a patch without changing it breaks the suffix link, and only the +# sha256 ties the new entry back to the old one. +sed "s| $wine_tail\$| 0098-winex11-an-entirely-different-description.patch|" \ + "$series" > "$tmp/renamed" +removals "$series" "$tmp/renamed" || fail 'series policy rejected a renamed patch' + # The number sits after the directory in a pipeasio/ entry, so the suffix has to # be taken from the basename or this series loses the renumbered-and-edited case. pipeasio_tail="$(awk '$2 ~ /^pipeasio\// { print $2 }' "$series" | sort | tail -1)" @@ -319,11 +325,14 @@ fi # Each series has its own gap table: 0027 is documented in one, pipeasio/0003 # in the other, so removing either needs no further reason. -{ echo "$wine_tail_hash 0027-a-retired-patch.patch"; cat "$series"; } > "$tmp/with-gap" +# A hash and a suffix of their own, or the entry matches a surviving patch and +# never reaches the gap tables. +retired_hash='1111111111111111111111111111111111111111111111111111111111111111' +{ echo "$retired_hash 0027-a-retired-wine-patch.patch"; cat "$series"; } > "$tmp/with-gap" removals "$tmp/with-gap" "$series" \ || fail 'series policy rejected the removal of a documented gap number' -{ echo "$wine_tail_hash pipeasio/0003-a-retired-patch.patch"; cat "$series"; } \ +{ echo "$retired_hash pipeasio/0003-a-retired-pipeasio-patch.patch"; cat "$series"; } \ > "$tmp/with-pipeasio-gap" removals "$tmp/with-pipeasio-gap" "$series" \ || fail 'series policy rejected the removal of a documented PipeASIO gap number'