Skip to content
Open
13 changes: 13 additions & 0 deletions .github/workflows/ci-pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ jobs:
steps:
- uses: actions/checkout@v7

- 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

- name: Read runtime version
id: runtime_version
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
Expand Down
125 changes: 96 additions & 29 deletions scripts/build-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,93 @@ 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 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
{
# 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_suffix[suffix($2)]; next
}
{
if ($1 in by_hash) next
if (suffix($2) in by_suffix) next
print $2
}
' "$2" "$1"
}

check_required_series_tails()
series_gap_reason() # manifest entry -> its documented gap reason, or nothing
{
local manifest="${1:?series manifest required}" 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)"
[ "$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 base="$1"
case "$1" in
pipeasio/*) base="${1#pipeasio/}"; printf '%s' "${PIPEASIO_GAPS[${base%%-*}]:-}" ;;
*) printf '%s' "${SERIES_GAPS[${base%%-*}]:-}" ;;
esac
}

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."
# 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 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+=("$entry")
fi
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):"
printf ' %s\n' "${unexplained[@]}"
}

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

Expand All @@ -42,12 +110,19 @@ if [ "${1:-}" = --source-tree-sha ]; then
fi

# --- --freeze: (re)generate the frozen series manifest ------------------------
# --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
new="$(cd "$root/patches" && sha256sum 00*.patch pipeasio/*.patch)"
check_required_series_tails <(printf '%s\n' "$new")
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
Expand All @@ -57,7 +132,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 --------------------
Expand Down Expand Up @@ -97,15 +171,12 @@ 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
# 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%%-*}"
Expand All @@ -124,10 +195,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/}"
Expand Down
6 changes: 3 additions & 3 deletions scripts/container-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
{
Expand Down
110 changes: 94 additions & 16 deletions scripts/test-release-policy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,29 +261,107 @@ 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)"
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]-}"

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'

# 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)"
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/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'
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_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'
# 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 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

# Each series has its own gap table: 0027 is documented in one, pipeasio/0003
# in the other, so removing either needs no further reason.
# 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 "$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'
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.
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'

# 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 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 - patch series cannot lose either required terminal member\n'
printf 'ok - --freeze records a renumber and stops on an undocumented deletion\n'