diff --git a/.github/workflows/ci-pr-build.yml b/.github/workflows/ci-pr-build.yml index 47f685f7..522b1390 100644 --- a/.github/workflows/ci-pr-build.yml +++ b/.github/workflows/ci-pr-build.yml @@ -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" diff --git a/scripts/build-audit.sh b/scripts/build-audit.sh index 58ea833d..16d6ec27 100755 --- a/scripts/build-audit.sh +++ b/scripts/build-audit.sh @@ -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 @@ -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 @@ -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 -------------------- @@ -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%%-*}" @@ -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/}" 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" { diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index a1e62c3d..6b6a1fba 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -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'