From 8bf18238799e54d1f461b4b463e90bd41ece6bae Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 25 Aug 2026 15:51:19 -0700 Subject: [PATCH 01/44] ci: add ABICheck shadow ABI scan --- .ci-local/abicheck-diff.sh | 106 ++++++++++++++++++++++++++ .github/workflows/abicheck-shadow.yml | 52 +++++++++++++ 2 files changed, 158 insertions(+) create mode 100755 .ci-local/abicheck-diff.sh create mode 100644 .github/workflows/abicheck-shadow.yml diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh new file mode 100755 index 000000000..4db249fac --- /dev/null +++ b/.ci-local/abicheck-diff.sh @@ -0,0 +1,106 @@ +#!/bin/sh +# Parallel ABI scan for pvxs. This is intentionally advisory: abi-diff.sh +# remains the release gate while this job establishes comparable evidence. +set -eu + +OLD=${1:-} +NEW=${2:-} + +if [ -z "$NEW" ]; then + NEW="$(git describe --tags)" +fi +if [ -z "$OLD" ]; then + OLD="$(git describe --tags --abbrev=0 "$NEW")" + if [ "$OLD" = "$NEW" ]; then + OLD="$(git describe --tags --abbrev=0 "$NEW"~)" + fi +fi +[ "$OLD" != "$NEW" ] || { echo "Refusing self-diff of $NEW" >&2; exit 64; } + +ABICHECK=${ABICHECK:-abicheck} +JOBS=${ABICHECK_MAKE_JOBS:-2} +REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} +RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} +mkdir -p "$RUN_ROOT" "$REPORT_ROOT" + +export HOME="$RUN_ROOT/home" +export XDG_CACHE_HOME="$RUN_ROOT/cache" +export TMPDIR="$RUN_ROOT/tmp" +mkdir -p "$HOME" "$XDG_CACHE_HOME" "$TMPDIR" + +# cue.py writes the EPICS base location here before this script is called. +EPICS_BASE=${EPICS_BASE:-} +if [ -z "$EPICS_BASE" ] && [ -f configure/RELEASE.local ]; then + EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' configure/RELEASE.local | tail -n 1) +fi +[ -n "$EPICS_BASE" ] && [ -d "$EPICS_BASE/include" ] || { + echo "EPICS_BASE include tree unavailable; run via 'python .ci/cue.py exec' after prepare" >&2 + exit 64 +} + +setupsrc() { + rev=$1 + dst=$2 + git archive "$rev" | tar -C "$RUN_ROOT" -xf - + mv "$RUN_ROOT"/pvxs-* "$dst" 2>/dev/null || { + mkdir -p "$dst" + git archive "$rev" | tar -C "$dst" -xf - + } + [ -f configure/RELEASE.local ] && cp configure/RELEASE.local "$dst/configure/" + [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$dst/configure/" + sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$dst"/configure/*.local 2>/dev/null || true + make -C "$dst" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" +} + +# git archive produces no enclosing directory, unlike the old helper. Keep +# each source tree fixed and separate so --sources evidence is side-specific. +OLD_SRC="$RUN_ROOT/old" +NEW_SRC="$RUN_ROOT/new" +mkdir -p "$OLD_SRC" "$NEW_SRC" +git archive "$OLD" | tar -C "$OLD_SRC" -xf - +git archive "$NEW" | tar -C "$NEW_SRC" -xf - +for src in "$OLD_SRC" "$NEW_SRC"; do + [ -f configure/RELEASE.local ] && cp configure/RELEASE.local "$src/configure/" + [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$src/configure/" + sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$src"/configure/*.local 2>/dev/null || true + make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" +done + +find_dso() { + find "$1/lib" -type f -name "$2.so.*" -print | LC_ALL=C sort | head -n 1 +} + +run_one() { + lib=$1 + oldso=$(find_dso "$OLD_SRC" "$lib") + newso=$(find_dso "$NEW_SRC" "$lib") + [ -n "$oldso" ] && [ -n "$newso" ] || { + echo "Unable to locate $lib in both builds" >&2 + return 64 + } + report="$REPORT_ROOT/${lib}_${OLD}_to_${NEW}.md" + + set +e + "$ABICHECK" compare "$oldso" "$newso" \ + --version "old=$OLD" --version "new=$NEW" \ + --header "old=$OLD_SRC/include" --header "new=$NEW_SRC/include" \ + --include "old:pvxs=$OLD_SRC/include" --include "new:pvxs=$NEW_SRC/include" \ + --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ + --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ + --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ + --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ + --require-complete-analysis --diagnostic-comparison --format review -o "$report" + rc=$? + set -e + + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$report" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true + fi + case "$rc" in + 0|1|2|4) echo "$lib: abicheck shadow verdict rc=$rc" ; return 0 ;; + *) echo "$lib: abicheck infrastructure failure rc=$rc" >&2; return "$rc" ;; + esac +} + +run_one libpvxs +run_one libpvxsIoc diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml new file mode 100644 index 000000000..cb668bbfb --- /dev/null +++ b/.github/workflows/abicheck-shadow.yml @@ -0,0 +1,52 @@ +name: ABICheck shadow ABI scan + +on: [push, pull_request, workflow_dispatch] + +# The scanner is an advisory, parallel signal. It needs no PR comments, no +# status write access, and no repository secrets. +permissions: + contents: read + +jobs: + abicheck-shadow: + runs-on: ubuntu-latest + env: + SETUP_PATH: .ci-local + SET: defaults + CMP: gcc + BCFG: default + BASE: "7.0" + _PVXS_ABORT_ON_CRIT: 1 + PVXS_LOG: pvxs.*=WARN + ABICHECK_MAKE_JOBS: "2" + ABICHECK_REF: ba70cdeecaa70ae8c0623182f6ffa71d514054f5 + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: "0" + submodules: true + persist-credentials: false + + - name: Install scanner and build prerequisites + run: | + sudo apt-get update + sudo apt-get -y install libreadline-dev libevent-dev cmake castxml + python -m pip install --disable-pip-version-check \ + "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" + abicheck --version + + - name: Prepare EPICS dependencies + run: python .ci/cue.py prepare + + - name: Run full source-evidence shadow scan + run: python .ci/cue.py exec ./.ci-local/abicheck-diff.sh + + - name: Upload ABICheck reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + retention-days: 7 + name: abicheck-shadow + path: compat_reports/abicheck + if-no-files-found: ignore From 0b9807912d9a58cf48f5ee39eb71cb2fdcf0a8ba Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 25 Aug 2026 18:00:07 -0700 Subject: [PATCH 02/44] ci: require comparable ABICheck shadow evidence --- .ci-local/abicheck-diff.sh | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 4db249fac..6cc79837f 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -4,11 +4,10 @@ set -eu OLD=${1:-} -NEW=${2:-} +# The default is the checked-out commit, not `git describe --tags`: a PR/push +# must compare its revision with the nearest preceding release. +NEW=${2:-HEAD} -if [ -z "$NEW" ]; then - NEW="$(git describe --tags)" -fi if [ -z "$OLD" ]; then OLD="$(git describe --tags --abbrev=0 "$NEW")" if [ "$OLD" = "$NEW" ]; then @@ -38,20 +37,6 @@ fi exit 64 } -setupsrc() { - rev=$1 - dst=$2 - git archive "$rev" | tar -C "$RUN_ROOT" -xf - - mv "$RUN_ROOT"/pvxs-* "$dst" 2>/dev/null || { - mkdir -p "$dst" - git archive "$rev" | tar -C "$dst" -xf - - } - [ -f configure/RELEASE.local ] && cp configure/RELEASE.local "$dst/configure/" - [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$dst/configure/" - sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$dst"/configure/*.local 2>/dev/null || true - make -C "$dst" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" -} - # git archive produces no enclosing directory, unlike the old helper. Keep # each source tree fixed and separate so --sources evidence is side-specific. OLD_SRC="$RUN_ROOT/old" @@ -89,7 +74,8 @@ run_one() { --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ - --require-complete-analysis --diagnostic-comparison --format review -o "$report" + --require-complete-analysis --format review \ + --write "json=${report%.md}.json" -o "$report" rc=$? set -e @@ -97,7 +83,8 @@ run_one() { cat "$report" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true fi case "$rc" in - 0|1|2|4) echo "$lib: abicheck shadow verdict rc=$rc" ; return 0 ;; + 0|2|4) echo "$lib: abicheck shadow verdict rc=$rc" ; return 0 ;; + 1) echo "$lib: incomplete analysis assurance; refusing an advisory verdict" >&2; return 1 ;; *) echo "$lib: abicheck infrastructure failure rc=$rc" >&2; return "$rc" ;; esac } From 24702cccef290e9a60ca7bfada4f28697ed0e7f5 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 25 Aug 2026 19:48:57 -0700 Subject: [PATCH 03/44] ci: install CastXML from conda-forge --- .github/workflows/abicheck-shadow.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index cb668bbfb..29a7b315d 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,9 +31,12 @@ jobs: - name: Install scanner and build prerequisites run: | sudo apt-get update - sudo apt-get -y install libreadline-dev libevent-dev cmake castxml + sudo apt-get -y install libreadline-dev libevent-dev cmake + /usr/share/miniconda/bin/conda install -y -c conda-forge castxml + echo "/usr/share/miniconda/bin" >> "$GITHUB_PATH" python -m pip install --disable-pip-version-check \ "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" + castxml --version abicheck --version - name: Prepare EPICS dependencies From 3201533a6ba5c1826cf342e84566c0148b428221 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Tue, 25 Aug 2026 21:05:54 -0700 Subject: [PATCH 04/44] ci: collect target-specific ABICheck evidence --- .ci-local/abicheck-diff.sh | 159 +++++++++++++++++--------- .github/workflows/abicheck-shadow.yml | 37 ++++-- 2 files changed, 134 insertions(+), 62 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 6cc79837f..e7c639d51 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -1,93 +1,144 @@ #!/bin/sh -# Parallel ABI scan for pvxs. This is intentionally advisory: abi-diff.sh -# remains the release gate while this job establishes comparable evidence. +# Advisory ABICheck scan. abi-diff.sh / ABICC remains authoritative. set -eu -OLD=${1:-} -# The default is the checked-out commit, not `git describe --tags`: a PR/push -# must compare its revision with the nearest preceding release. -NEW=${2:-HEAD} - -if [ -z "$OLD" ]; then - OLD="$(git describe --tags --abbrev=0 "$NEW")" - if [ "$OLD" = "$NEW" ]; then - OLD="$(git describe --tags --abbrev=0 "$NEW"~)" - fi -fi -[ "$OLD" != "$NEW" ] || { echo "Refusing self-diff of $NEW" >&2; exit 64; } - +OLD_REF=${1:-} +NEW_REF=${2:-HEAD} ABICHECK=${ABICHECK:-abicheck} JOBS=${ABICHECK_MAKE_JOBS:-2} REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} -mkdir -p "$RUN_ROOT" "$REPORT_ROOT" +new_sha=$(git rev-parse "$NEW_REF^{commit}") +if [ -z "$OLD_REF" ]; then + OLD_REF=$(git describe --tags --abbrev=0 "$new_sha") +fi +old_sha=$(git rev-parse "$OLD_REF^{commit}") +if [ "$old_sha" = "$new_sha" ]; then + OLD_REF=$(git describe --tags --abbrev=0 "$new_sha^") + old_sha=$(git rev-parse "$OLD_REF^{commit}") +fi + +mkdir -p "$RUN_ROOT" "$REPORT_ROOT" export HOME="$RUN_ROOT/home" export XDG_CACHE_HOME="$RUN_ROOT/cache" export TMPDIR="$RUN_ROOT/tmp" mkdir -p "$HOME" "$XDG_CACHE_HOME" "$TMPDIR" -# cue.py writes the EPICS base location here before this script is called. EPICS_BASE=${EPICS_BASE:-} if [ -z "$EPICS_BASE" ] && [ -f configure/RELEASE.local ]; then EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' configure/RELEASE.local | tail -n 1) fi [ -n "$EPICS_BASE" ] && [ -d "$EPICS_BASE/include" ] || { - echo "EPICS_BASE include tree unavailable; run via 'python .ci/cue.py exec' after prepare" >&2 + echo "EPICS_BASE include tree unavailable; use cue.py exec after prepare" >&2 exit 64 } +command -v bear >/dev/null || { echo "bear is required for complete build evidence" >&2; exit 64; } -# git archive produces no enclosing directory, unlike the old helper. Keep -# each source tree fixed and separate so --sources evidence is side-specific. OLD_SRC="$RUN_ROOT/old" NEW_SRC="$RUN_ROOT/new" +# git archive has no enclosing directory; extract each revision into its own +# fixed root so paths and build evidence remain side-specific. mkdir -p "$OLD_SRC" "$NEW_SRC" -git archive "$OLD" | tar -C "$OLD_SRC" -xf - -git archive "$NEW" | tar -C "$NEW_SRC" -xf - -for src in "$OLD_SRC" "$NEW_SRC"; do +git archive "$old_sha" | tar -C "$OLD_SRC" -xf - +git archive "$new_sha" | tar -C "$NEW_SRC" -xf - + +prepare_build() { + src=$1 [ -f configure/RELEASE.local ] && cp configure/RELEASE.local "$src/configure/" [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$src/configure/" sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$src"/configure/*.local 2>/dev/null || true - make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" -done + bear --output "$src/compile_commands.json" -- \ + make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" +} + +prepare_build "$OLD_SRC" +prepare_build "$NEW_SRC" + +stage_headers() { + src=$1 + target=$2 + out=$3 + mkdir -p "$out/pvxs" + if [ "$target" = libpvxs ]; then + awk '/^INC[[:space:]]*\+=[[:space:]]*pvxs\// {print $3}' "$src/src/Makefile" | while read -r header; do + [ -f "$src/include/$header" ] || { echo "missing public header $header" >&2; exit 64; } + mkdir -p "$out/$(dirname "$header")" + cp "$src/include/$header" "$out/$header" + done + else + cp "$src/include/pvxs/iochooks.h" "$out/pvxs/iochooks.h" + fi +} + +project_compile_db() { + src=$1 + target=$2 + out=$3 + python3 - "$src/compile_commands.json" "$target" "$out" <<'PY' +import json, pathlib, sys +entries = json.load(open(sys.argv[1])) +target, out = sys.argv[2:] +needle = '/src/' if target == 'libpvxs' else '/ioc/' +selected = [e for e in entries if needle in pathlib.PurePosixPath(e['file']).as_posix()] +if not selected: + raise SystemExit(f'no compile commands selected for {target}') +json.dump(selected, open(out, 'w'), indent=2) +PY +} find_dso() { find "$1/lib" -type f -name "$2.so.*" -print | LC_ALL=C sort | head -n 1 } -run_one() { - lib=$1 - oldso=$(find_dso "$OLD_SRC" "$lib") - newso=$(find_dso "$NEW_SRC" "$lib") - [ -n "$oldso" ] && [ -n "$newso" ] || { - echo "Unable to locate $lib in both builds" >&2 - return 64 - } - report="$REPORT_ROOT/${lib}_${OLD}_to_${NEW}.md" +old_id=$(printf '%s' "$old_sha" | cut -c1-12) +new_id=$(printf '%s' "$new_sha" | cut -c1-12) +status_file="$REPORT_ROOT/summary.json" +printf '{"old_ref":"%s","old_sha":"%s","new_ref":"%s","new_sha":"%s","targets":[' \ + "$OLD_REF" "$old_sha" "$NEW_REF" "$new_sha" > "$status_file" +first=1 +overall=0 +run_one() { + target=$1 + oldso=$(find_dso "$OLD_SRC" "$target") + newso=$(find_dso "$NEW_SRC" "$target") + [ -n "$oldso" ] && [ -n "$newso" ] || return 64 + old_headers="$RUN_ROOT/headers-old-$target" + new_headers="$RUN_ROOT/headers-new-$target" + stage_headers "$OLD_SRC" "$target" "$old_headers" + stage_headers "$NEW_SRC" "$target" "$new_headers" + old_db="$OLD_SRC/compile_commands.$target.json" + new_db="$NEW_SRC/compile_commands.$target.json" + project_compile_db "$OLD_SRC" "$target" "$old_db" + project_compile_db "$NEW_SRC" "$target" "$new_db" + base="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" set +e "$ABICHECK" compare "$oldso" "$newso" \ - --version "old=$OLD" --version "new=$NEW" \ - --header "old=$OLD_SRC/include" --header "new=$NEW_SRC/include" \ - --include "old:pvxs=$OLD_SRC/include" --include "new:pvxs=$NEW_SRC/include" \ - --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ - --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ - --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ - --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ - --require-complete-analysis --format review \ - --write "json=${report%.md}.json" -o "$report" + --version "old=$old_sha" --version "new=$new_sha" \ + --header "old=$old_headers" --header "new=$new_headers" \ + --include "old:pvxs=$OLD_SRC/include" --include "new:pvxs=$NEW_SRC/include" \ + --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ + --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ + --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ + --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ + --build-info "old=$old_db" --build-info "new=$new_db" \ + --require-complete-analysis --format review --write "json=$base.json" -o "$base.md" rc=$? set -e - - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - cat "$report" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true - fi - case "$rc" in - 0|2|4) echo "$lib: abicheck shadow verdict rc=$rc" ; return 0 ;; - 1) echo "$lib: incomplete analysis assurance; refusing an advisory verdict" >&2; return 1 ;; - *) echo "$lib: abicheck infrastructure failure rc=$rc" >&2; return "$rc" ;; - esac + if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi + [ "$first" -eq 1 ] || printf ',' >> "$status_file" + first=0 + printf '{"target":"%s","exit_code":%s,"report":"%s.json"}' "$target" "$rc" "$(basename "$base")" >> "$status_file" + case "$rc" in 0|2|4) return 0;; *) return "$rc";; esac } -run_one libpvxs -run_one libpvxsIoc +for target in libpvxs libpvxsIoc; do + set +e + run_one "$target" + rc=$? + set -e + [ "$rc" -eq 0 ] || overall=1 +done +printf '],"integration_health":%s}\n' "$overall" >> "$status_file" +exit "$overall" diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index 29a7b315d..b087af305 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -1,6 +1,15 @@ name: ABICheck shadow ABI scan -on: [push, pull_request, workflow_dispatch] +on: + pull_request: + push: + branches: [master] + tags: ['*'] + workflow_dispatch: + +concurrency: + group: abicheck-shadow-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true # The scanner is an advisory, parallel signal. It needs no PR comments, no # status write access, and no repository secrets. @@ -10,6 +19,7 @@ permissions: jobs: abicheck-shadow: runs-on: ubuntu-latest + timeout-minutes: 45 env: SETUP_PATH: .ci-local SET: defaults @@ -28,16 +38,27 @@ jobs: submodules: true persist-credentials: false - - name: Install scanner and build prerequisites + - name: Install scanner tools + shell: bash run: | + set -euxo pipefail sudo apt-get update - sudo apt-get -y install libreadline-dev libevent-dev cmake + sudo apt-get -y install libreadline-dev libevent-dev cmake bear /usr/share/miniconda/bin/conda install -y -c conda-forge castxml - echo "/usr/share/miniconda/bin" >> "$GITHUB_PATH" - python -m pip install --disable-pip-version-check \ + VENV="$RUNNER_TEMP/abicheck-venv" + python -m venv "$VENV" + "$VENV/bin/pip" install --disable-pip-version-check \ "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" + echo "/usr/share/miniconda/bin" >> "$GITHUB_PATH" + echo "$VENV/bin" >> "$GITHUB_PATH" + echo "ABICHECK=$VENV/bin/abicheck" >> "$GITHUB_ENV" + + - name: Verify scanner tools + shell: bash + run: | + set -euxo pipefail castxml --version - abicheck --version + "$ABICHECK" --version - name: Prepare EPICS dependencies run: python .ci/cue.py prepare @@ -49,7 +70,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v7 with: - retention-days: 7 + retention-days: 30 name: abicheck-shadow path: compat_reports/abicheck - if-no-files-found: ignore + if-no-files-found: error From 60f760374ca6c4432ace466687b28a12b1f55525 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 26 Aug 2026 08:22:39 -0700 Subject: [PATCH 05/44] ci: retain ABICheck target failures --- .ci-local/abicheck-diff.sh | 48 ++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index e7c639d51..dd1c2a84d 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -106,15 +106,14 @@ run_one() { [ -n "$oldso" ] && [ -n "$newso" ] || return 64 old_headers="$RUN_ROOT/headers-old-$target" new_headers="$RUN_ROOT/headers-new-$target" - stage_headers "$OLD_SRC" "$target" "$old_headers" - stage_headers "$NEW_SRC" "$target" "$new_headers" + stage_headers "$OLD_SRC" "$target" "$old_headers" || return $? + stage_headers "$NEW_SRC" "$target" "$new_headers" || return $? old_db="$OLD_SRC/compile_commands.$target.json" new_db="$NEW_SRC/compile_commands.$target.json" - project_compile_db "$OLD_SRC" "$target" "$old_db" - project_compile_db "$NEW_SRC" "$target" "$new_db" + project_compile_db "$OLD_SRC" "$target" "$old_db" || return $? + project_compile_db "$NEW_SRC" "$target" "$new_db" || return $? base="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" - set +e - "$ABICHECK" compare "$oldso" "$newso" \ + if "$ABICHECK" compare "$oldso" "$newso" \ --version "old=$old_sha" --version "new=$new_sha" \ --header "old=$old_headers" --header "new=$new_headers" \ --include "old:pvxs=$OLD_SRC/include" --include "new:pvxs=$NEW_SRC/include" \ @@ -124,21 +123,40 @@ run_one() { --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ --build-info "old=$old_db" --build-info "new=$new_db" \ --require-complete-analysis --format review --write "json=$base.json" -o "$base.md" - rc=$? - set -e + then + rc=0 + else + rc=$? + fi + printf '%s\n' "$rc" > "$RUN_ROOT/$target.exit-code" if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi + case "$rc" in 0|2|4) return 0;; *) return "$rc";; esac +} + +append_target() { + target=$1 + rc=$2 + base="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" [ "$first" -eq 1 ] || printf ',' >> "$status_file" first=0 - printf '{"target":"%s","exit_code":%s,"report":"%s.json"}' "$target" "$rc" "$(basename "$base")" >> "$status_file" - case "$rc" in 0|2|4) return 0;; *) return "$rc";; esac + if [ -f "$base.json" ]; then + printf '{"target":"%s","exit_code":%s,"report":"%s.json"}' "$target" "$rc" "$(basename "$base")" >> "$status_file" + else + printf '{"target":"%s","exit_code":%s,"report":null}' "$target" "$rc" >> "$status_file" + fi } for target in libpvxs libpvxsIoc; do - set +e - run_one "$target" - rc=$? - set -e - [ "$rc" -eq 0 ] || overall=1 + if run_one "$target"; then + rc=0 + else + rc=$? + fi + if [ -f "$RUN_ROOT/$target.exit-code" ]; then + rc=$(cat "$RUN_ROOT/$target.exit-code") + fi + append_target "$target" "$rc" + case "$rc" in 0|2|4) ;; *) overall=1;; esac done printf '],"integration_health":%s}\n' "$overall" >> "$status_file" exit "$overall" From d95ba933a0a1dc8294df2e1f43a57e1e8b560f3e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 26 Aug 2026 10:42:39 -0700 Subject: [PATCH 06/44] ci: record pvxs C++ dialect in scan evidence --- .ci-local/abicheck-diff.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index dd1c2a84d..7c6160a6a 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -6,6 +6,9 @@ OLD_REF=${1:-} NEW_REF=${2:-HEAD} ABICHECK=${ABICHECK:-abicheck} JOBS=${ABICHECK_MAKE_JOBS:-2} +# GCC 13 defaults to C++17, while CastXML otherwise falls back to an older +# dialect when replaying the compile database. Make the build dialect explicit. +ABICHECK_OPT_CXXFLAGS=${ABICHECK_OPT_CXXFLAGS:--g -Og -std=c++17} REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} @@ -49,7 +52,8 @@ prepare_build() { [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$src/configure/" sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$src"/configure/*.local 2>/dev/null || true bear --output "$src/compile_commands.json" -- \ - make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" + make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' \ + OPT_CXXFLAGS="$ABICHECK_OPT_CXXFLAGS" ioc -j"$JOBS" } prepare_build "$OLD_SRC" From 8fe7884411159b1e8ea4bc6efe535587ffca149a Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 26 Aug 2026 13:55:31 -0700 Subject: [PATCH 07/44] ci: override CastXML dialect for GCC 13 --- .ci-local/abicheck-diff.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 7c6160a6a..31a22bb2f 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -6,9 +6,9 @@ OLD_REF=${1:-} NEW_REF=${2:-HEAD} ABICHECK=${ABICHECK:-abicheck} JOBS=${ABICHECK_MAKE_JOBS:-2} -# GCC 13 defaults to C++17, while CastXML otherwise falls back to an older -# dialect when replaying the compile database. Make the build dialect explicit. -ABICHECK_OPT_CXXFLAGS=${ABICHECK_OPT_CXXFLAGS:--g -Og -std=c++17} +# CastXML bundled with GCC 13 cannot parse libstdc++ in PVXS's C++11 mode. +# This affects only AST extraction; the binary remains built with PVXS defaults. +ABICHECK_CASTXML_CXXSTD=${ABICHECK_CASTXML_CXXSTD:-c++17} REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} @@ -52,8 +52,7 @@ prepare_build() { [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$src/configure/" sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$src"/configure/*.local 2>/dev/null || true bear --output "$src/compile_commands.json" -- \ - make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' \ - OPT_CXXFLAGS="$ABICHECK_OPT_CXXFLAGS" ioc -j"$JOBS" + make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" } prepare_build "$OLD_SRC" @@ -126,6 +125,7 @@ run_one() { --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ --build-info "old=$old_db" --build-info "new=$new_db" \ + --compiler-option "-std=$ABICHECK_CASTXML_CXXSTD" \ --require-complete-analysis --format review --write "json=$base.json" -o "$base.md" then rc=0 From 2a26a190a47428b51afb630fb0e4bda4c3c6fd61 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 26 Aug 2026 19:09:57 -0700 Subject: [PATCH 08/44] ci: allow complete pvxs shadow scans --- .github/workflows/abicheck-shadow.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index b087af305..f3eaf04c1 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -19,7 +19,9 @@ permissions: jobs: abicheck-shadow: runs-on: ubuntu-latest - timeout-minutes: 45 + # A full source-evidence scan runs two revisions of both public DSOs. + # Keep this above the measured ~100-minute worst case, not at 45 minutes. + timeout-minutes: 120 env: SETUP_PATH: .ci-local SET: defaults From 839b5118c7d7b34475631747c5d7c22c940a1976 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 11 Sep 2026 10:34:15 -0700 Subject: [PATCH 09/44] ci: stage PVXS public headers for shadow ABI scan --- .ci-local/abicheck-diff.sh | 8 +++++--- .github/workflows/abicheck-shadow.yml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 31a22bb2f..09427ab11 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -65,12 +65,14 @@ stage_headers() { mkdir -p "$out/pvxs" if [ "$target" = libpvxs ]; then awk '/^INC[[:space:]]*\+=[[:space:]]*pvxs\// {print $3}' "$src/src/Makefile" | while read -r header; do - [ -f "$src/include/$header" ] || { echo "missing public header $header" >&2; exit 64; } + header_src="$src/src/$header" + [ "$header" != pvxs/versionNum.h ] || header_src="$src/src/O.Common/$header" + [ -f "$header_src" ] || { echo "missing public header $header" >&2; exit 64; } mkdir -p "$out/$(dirname "$header")" - cp "$src/include/$header" "$out/$header" + cp "$header_src" "$out/$header" done else - cp "$src/include/pvxs/iochooks.h" "$out/pvxs/iochooks.h" + cp "$src/ioc/pvxs/iochooks.h" "$out/pvxs/iochooks.h" fi } diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index f3eaf04c1..261d7bb2b 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,7 +31,7 @@ jobs: _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: ba70cdeecaa70ae8c0623182f6ffa71d514054f5 + ABICHECK_REF: 4195049e550eb7bb0f9feb474559319832c7f8a4 steps: - uses: actions/checkout@v6 From d2d4c618b397d897d56fd18dd2840756fe0723bd Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 11 Sep 2026 15:46:29 -0700 Subject: [PATCH 10/44] ci: harden PVXS ABICheck shadow integration --- .ci-local/abicheck-diff.sh | 52 ++++++++++++++++++++------- .ci-local/abicheck.yml | 8 +++++ .github/workflows/abicheck-shadow.yml | 2 +- 3 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 .ci-local/abicheck.yml diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 09427ab11..1dc39151d 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -6,18 +6,17 @@ OLD_REF=${1:-} NEW_REF=${2:-HEAD} ABICHECK=${ABICHECK:-abicheck} JOBS=${ABICHECK_MAKE_JOBS:-2} -# CastXML bundled with GCC 13 cannot parse libstdc++ in PVXS's C++11 mode. -# This affects only AST extraction; the binary remains built with PVXS defaults. -ABICHECK_CASTXML_CXXSTD=${ABICHECK_CASTXML_CXXSTD:-c++17} REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} +explicit_old=1 new_sha=$(git rev-parse "$NEW_REF^{commit}") if [ -z "$OLD_REF" ]; then + explicit_old=0 OLD_REF=$(git describe --tags --abbrev=0 "$new_sha") fi old_sha=$(git rev-parse "$OLD_REF^{commit}") -if [ "$old_sha" = "$new_sha" ]; then +if [ "$explicit_old" -eq 0 ] && [ "$old_sha" = "$new_sha" ]; then OLD_REF=$(git describe --tags --abbrev=0 "$new_sha^") old_sha=$(git rev-parse "$OLD_REF^{commit}") fi @@ -80,12 +79,22 @@ project_compile_db() { src=$1 target=$2 out=$3 - python3 - "$src/compile_commands.json" "$target" "$out" <<'PY' + python3 - "$src/compile_commands.json" "$src" "$target" "$out" <<'PY' import json, pathlib, sys entries = json.load(open(sys.argv[1])) -target, out = sys.argv[2:] -needle = '/src/' if target == 'libpvxs' else '/ioc/' -selected = [e for e in entries if needle in pathlib.PurePosixPath(e['file']).as_posix()] +source_root = pathlib.Path(sys.argv[2]).resolve() +target, out = sys.argv[3:] +component_root = (source_root / ('src' if target == 'libpvxs' else 'ioc')).resolve() +selected = [] +for entry in entries: + source = pathlib.Path(entry['file']) + if not source.is_absolute(): + source = pathlib.Path(entry.get('directory') or source_root) / source + try: + source.resolve().relative_to(component_root) + except ValueError: + continue + selected.append(entry) if not selected: raise SystemExit(f'no compile commands selected for {target}') json.dump(selected, open(out, 'w'), indent=2) @@ -93,7 +102,13 @@ PY } find_dso() { - find "$1/lib" -type f -name "$2.so.*" -print | LC_ALL=C sort | head -n 1 + matches=$(find "$1/lib" -type f -name "$2.so.*" -print | LC_ALL=C sort) + count=$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l) + [ "$count" -eq 1 ] || { + echo "expected one $2 DSO below $1/lib, found $count" >&2 + return 64 + } + printf '%s\n' "$matches" } old_id=$(printf '%s' "$old_sha" | cut -c1-12) @@ -121,19 +136,32 @@ run_one() { if "$ABICHECK" compare "$oldso" "$newso" \ --version "old=$old_sha" --version "new=$new_sha" \ --header "old=$old_headers" --header "new=$new_headers" \ - --include "old:pvxs=$OLD_SRC/include" --include "new:pvxs=$NEW_SRC/include" \ + --include "old:pvxs=$old_headers" --include "new:pvxs=$new_headers" \ --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ --build-info "old=$old_db" --build-info "new=$new_db" \ - --compiler-option "-std=$ABICHECK_CASTXML_CXXSTD" \ - --require-complete-analysis --format review --write "json=$base.json" -o "$base.md" + --config "$PWD/.ci-local/abicheck.yml" \ + --format review --write "json=$base.json" -o "$base.md" then rc=0 else rc=$? fi + if [ ! -s "$base.json" ] || [ ! -s "$base.md" ]; then + echo "missing comparison reports for $target" >&2 + rc=64 + elif ! python3 - "$base.json" <<'PY' +import json, sys +report = json.load(open(sys.argv[1])) +if report.get("analysis_assurance_exit_contribution") not in (0, None): + raise SystemExit("analysis assurance is incomplete") +PY + then + echo "incomplete analysis assurance for $target" >&2 + rc=1 + fi printf '%s\n' "$rc" > "$RUN_ROOT/$target.exit-code" if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi case "$rc" in 0|2|4) return 0;; *) return "$rc";; esac diff --git a/.ci-local/abicheck.yml b/.ci-local/abicheck.yml new file mode 100644 index 000000000..597a71674 --- /dev/null +++ b/.ci-local/abicheck.yml @@ -0,0 +1,8 @@ +# CastXML packaged on the GitHub runner cannot parse libstdc++ with PVXS's +# default C++11 mode. This is deliberately recorded as extraction context; +# the libraries themselves keep PVXS's normal build flags. +compile: + options: + - -std=c++17 +assurance: + require_complete: true diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index 261d7bb2b..3a48386e1 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,7 +31,7 @@ jobs: _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: 4195049e550eb7bb0f9feb474559319832c7f8a4 + ABICHECK_REF: 0ab7da2eba3016bcad86657fb74239c66ef3ffeb steps: - uses: actions/checkout@v6 From 3825f0d41a851ee9e6099f005d98bce36c8f8c83 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 11 Sep 2026 20:05:20 -0700 Subject: [PATCH 11/44] ci: fail closed on invalid ABI evidence --- .ci-local/abicheck-diff.sh | 47 ++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 1dc39151d..8c8b0caa3 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -61,18 +61,21 @@ stage_headers() { src=$1 target=$2 out=$3 - mkdir -p "$out/pvxs" if [ "$target" = libpvxs ]; then - awk '/^INC[[:space:]]*\+=[[:space:]]*pvxs\// {print $3}' "$src/src/Makefile" | while read -r header; do - header_src="$src/src/$header" - [ "$header" != pvxs/versionNum.h ] || header_src="$src/src/O.Common/$header" - [ -f "$header_src" ] || { echo "missing public header $header" >&2; exit 64; } - mkdir -p "$out/$(dirname "$header")" - cp "$header_src" "$out/$header" - done + installed="$src/src/O.Common/pvxs" else - cp "$src/ioc/pvxs/iochooks.h" "$out/pvxs/iochooks.h" + installed="$src/ioc/O.Common/pvxs" fi + [ -d "$installed" ] || { echo "missing installed public header root $installed" >&2; return 64; } + headers=$(find "$installed" -type f -name '*.h' -print | LC_ALL=C sort) + [ -n "$headers" ] || { echo "no installed public headers below $installed" >&2; return 64; } + while IFS= read -r header_src; do + header=${header_src#"$installed"/} + mkdir -p "$out/$(dirname "$header")" + cp "$header_src" "$out/$header" + done <&2 + echo "invalid or incomplete analysis assurance for $target" >&2 rc=1 + else + cp "$base.json" "$published.json" + cp "$base.md" "$published.md" + : > "$RUN_ROOT/$target.report-ready" fi printf '%s\n' "$rc" > "$RUN_ROOT/$target.exit-code" if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi @@ -173,7 +190,7 @@ append_target() { base="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" [ "$first" -eq 1 ] || printf ',' >> "$status_file" first=0 - if [ -f "$base.json" ]; then + if [ -f "$RUN_ROOT/$target.report-ready" ] && [ -f "$base.json" ]; then printf '{"target":"%s","exit_code":%s,"report":"%s.json"}' "$target" "$rc" "$(basename "$base")" >> "$status_file" else printf '{"target":"%s","exit_code":%s,"report":null}' "$target" "$rc" >> "$status_file" From d0b31bac5c51124044c9c140c52c09f2fa43bee8 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 11 Sep 2026 20:48:08 -0700 Subject: [PATCH 12/44] ci: separate public and generated PVXS headers --- .ci-local/abicheck-diff.sh | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 8c8b0caa3..d430c3a9a 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -61,21 +61,26 @@ stage_headers() { src=$1 target=$2 out=$3 + public="$out/public" + support="$out/support" if [ "$target" = libpvxs ]; then installed="$src/src/O.Common/pvxs" - else - installed="$src/ioc/O.Common/pvxs" - fi - [ -d "$installed" ] || { echo "missing installed public header root $installed" >&2; return 64; } - headers=$(find "$installed" -type f -name '*.h' -print | LC_ALL=C sort) - [ -n "$headers" ] || { echo "no installed public headers below $installed" >&2; return 64; } - while IFS= read -r header_src; do - header=${header_src#"$installed"/} - mkdir -p "$out/$(dirname "$header")" - cp "$header_src" "$out/$header" - done <&2; return 64; } + headers=$(find "$installed" -type f -name '*.h' ! -name versionNum.h -print | LC_ALL=C sort) + [ -n "$headers" ] || { echo "no installed public headers below $installed" >&2; return 64; } + while IFS= read -r header_src; do + header=${header_src#"$installed"/} + mkdir -p "$public/$(dirname "$header")" + cp "$header_src" "$public/$header" + done < Date: Fri, 11 Sep 2026 20:57:20 -0700 Subject: [PATCH 13/44] ci: stage declared PVXS header sources --- .ci-local/abicheck-diff.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index d430c3a9a..c0c2dfe76 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -64,19 +64,20 @@ stage_headers() { public="$out/public" support="$out/support" if [ "$target" = libpvxs ]; then - installed="$src/src/O.Common/pvxs" - [ -d "$installed" ] || { echo "missing installed public header root $installed" >&2; return 64; } - headers=$(find "$installed" -type f -name '*.h' ! -name versionNum.h -print | LC_ALL=C sort) - [ -n "$headers" ] || { echo "no installed public headers below $installed" >&2; return 64; } + source_headers="$src/src/pvxs" + generated_headers="$src/src/O.Common/pvxs" + [ -d "$source_headers" ] || { echo "missing PVXS public header root $source_headers" >&2; return 64; } + [ -f "$generated_headers/versionNum.h" ] || { echo "missing generated version header" >&2; return 64; } + headers=$(find "$source_headers" -maxdepth 1 -type f -name '*.h' -print | LC_ALL=C sort) + [ -n "$headers" ] || { echo "no PVXS public headers below $source_headers" >&2; return 64; } + mkdir -p "$public/pvxs" while IFS= read -r header_src; do - header=${header_src#"$installed"/} - mkdir -p "$public/$(dirname "$header")" - cp "$header_src" "$public/$header" + cp "$header_src" "$public/pvxs/$(basename "$header_src")" done < Date: Fri, 11 Sep 2026 21:06:55 -0700 Subject: [PATCH 14/44] ci: escape ABI summary references --- .ci-local/abicheck-diff.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index c0c2dfe76..c7064db2c 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -123,8 +123,16 @@ find_dso() { old_id=$(printf '%s' "$old_sha" | cut -c1-12) new_id=$(printf '%s' "$new_sha" | cut -c1-12) status_file="$REPORT_ROOT/summary.json" -printf '{"old_ref":"%s","old_sha":"%s","new_ref":"%s","new_sha":"%s","targets":[' \ - "$OLD_REF" "$old_sha" "$NEW_REF" "$new_sha" > "$status_file" +python3 - "$status_file" "$OLD_REF" "$old_sha" "$NEW_REF" "$new_sha" <<'PY' +import json, sys +path, old_ref, old_sha, new_ref, new_sha = sys.argv[1:] +with open(path, "w") as output: + output.write(json.dumps({ + "old_ref": old_ref, "old_sha": old_sha, + "new_ref": new_ref, "new_sha": new_sha, + }, separators=(",", ":"))[:-1]) + output.write(',"targets":[') +PY first=1 overall=0 From f4f576bac15879daed3457000fed39b93894903e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 12 Sep 2026 14:48:46 -0700 Subject: [PATCH 15/44] ci: fail closed when staging ABI reports --- .ci-local/abicheck-diff.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index c7064db2c..25193a073 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -70,17 +70,17 @@ stage_headers() { [ -f "$generated_headers/versionNum.h" ] || { echo "missing generated version header" >&2; return 64; } headers=$(find "$source_headers" -maxdepth 1 -type f -name '*.h' -print | LC_ALL=C sort) [ -n "$headers" ] || { echo "no PVXS public headers below $source_headers" >&2; return 64; } - mkdir -p "$public/pvxs" + mkdir -p "$public/pvxs" || return 64 while IFS= read -r header_src; do - cp "$header_src" "$public/pvxs/$(basename "$header_src")" + cp "$header_src" "$public/pvxs/$(basename "$header_src")" || return 64 done <&2 rc=1 - else - cp "$base.json" "$published.json" - cp "$base.md" "$published.md" + elif cp "$base.json" "$published.json" && cp "$base.md" "$published.md"; then : > "$RUN_ROOT/$target.report-ready" + else + echo "failed to publish comparison reports for $target" >&2 + rm -f "$published.json" "$published.md" "$RUN_ROOT/$target.report-ready" + rc=64 fi printf '%s\n' "$rc" > "$RUN_ROOT/$target.exit-code" if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi From c4de3aee73a2ea4ab28212376af752d36b190ace Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 12 Sep 2026 19:39:55 -0700 Subject: [PATCH 16/44] ci: use L2 ABI scan in shadow workflow --- .ci-local/abicheck-diff.sh | 2 +- .github/workflows/abicheck-shadow.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index 25193a073..ca86898d4 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -162,7 +162,7 @@ run_one() { --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ - --depth source --sources "old=$OLD_SRC" --sources "new=$NEW_SRC" \ + --depth build \ --build-info "old=$old_db" --build-info "new=$new_db" \ --config "$PWD/.ci-local/abicheck.yml" \ --format review --write "json=$base.json" -o "$base.md" diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index 3a48386e1..0c4b29a70 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -19,7 +19,7 @@ permissions: jobs: abicheck-shadow: runs-on: ubuntu-latest - # A full source-evidence scan runs two revisions of both public DSOs. + # An L2 build-evidence scan runs two revisions of both public DSOs. # Keep this above the measured ~100-minute worst case, not at 45 minutes. timeout-minutes: 120 env: @@ -65,7 +65,7 @@ jobs: - name: Prepare EPICS dependencies run: python .ci/cue.py prepare - - name: Run full source-evidence shadow scan + - name: Run L2 build-evidence shadow scan run: python .ci/cue.py exec ./.ci-local/abicheck-diff.sh - name: Upload ABICheck reports From e955819bb0e2f2d876552343bff6b2ab201c17a2 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 12 Sep 2026 22:00:15 -0700 Subject: [PATCH 17/44] ci: update ABICheck scanner to latest main --- .github/workflows/abicheck-shadow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index 0c4b29a70..fc3e7460a 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,7 +31,7 @@ jobs: _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: 0ab7da2eba3016bcad86657fb74239c66ef3ffeb + ABICHECK_REF: 44f48dda3584559ba0c51eee24ee9bebfeeff74c steps: - uses: actions/checkout@v6 From 07c17aa6cbf1822136949c79224dbcc35d5ae39e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 12 Sep 2026 22:26:20 -0700 Subject: [PATCH 18/44] ci: use current ABICheck export syntax --- .ci-local/abicheck-diff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh index ca86898d4..1625794f1 100755 --- a/.ci-local/abicheck-diff.sh +++ b/.ci-local/abicheck-diff.sh @@ -165,7 +165,7 @@ run_one() { --depth build \ --build-info "old=$old_db" --build-info "new=$new_db" \ --config "$PWD/.ci-local/abicheck.yml" \ - --format review --write "json=$base.json" -o "$base.md" + -o "review=$base.md" -o "json=$base.json" then rc=0 else From d1023b8f2560229c208b970bf906bdd459633b56 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 13 Sep 2026 08:39:04 -0700 Subject: [PATCH 19/44] ci: refresh ABICheck main pin --- .github/workflows/abicheck-shadow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index fc3e7460a..6e80f09e1 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,7 +31,7 @@ jobs: _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: 44f48dda3584559ba0c51eee24ee9bebfeeff74c + ABICHECK_REF: api_response: body: fb423dfd62c267b0db61739941f2d35ee2dacb16 truncated: false steps: - uses: actions/checkout@v6 From a5c52a6ef5f855d9386df0db7cb3112036003c89 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 13 Sep 2026 08:39:16 -0700 Subject: [PATCH 20/44] ci: correct ABICheck main revision pin --- .github/workflows/abicheck-shadow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml index 6e80f09e1..82d2dda7d 100644 --- a/.github/workflows/abicheck-shadow.yml +++ b/.github/workflows/abicheck-shadow.yml @@ -31,7 +31,7 @@ jobs: _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: api_response: body: fb423dfd62c267b0db61739941f2d35ee2dacb16 truncated: false + ABICHECK_REF: fb423dfd62c267b0db61739941f2d35ee2dacb16 steps: - uses: actions/checkout@v6 From 6b096a190537bc6218f0fa9db9c321f4e2b9f7de Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 03:59:30 +0000 Subject: [PATCH 21/44] ci: reuse the normal build for the ABI check and publish it safely Replace the 232-line .ci-local/abicheck-diff.sh wrapper and its standalone workflow with an integration that consumes the build PVXS already performs. Build reuse The capture now runs inside the existing "Native Linux (WError)" matrix leg, marked with `abicheck: 1`, after that leg's own tests. It reads the installed include/pvxs headers and the installed shared objects; it no longer rebuilds both revisions under Bear, copies headers out of src/ and ioc/, parses Makefiles, rewrites configure/*.local, or moves HOME. Each component is captured exactly once per revision and that capture is reused for every comparison, the job summary and the PR comment. Scope Evidence depth drops from L3 to L2 (--depth headers). Build-drift coverage is intentionally removed, not silently retained; this is stated in documentation/abicheck.md rather than implied. L4/L5 stay off. Ownership libpvxs owns every installed public header except iochooks.h; libpvxsIoc owns iochooks.h alone, with the core and EPICS headers as include-only context. The two shared objects are resolved explicitly and validated, so symlinks are not analysed as duplicate components and static archives are never eligible. Baselines accepted-main resolves the default-branch run for the pull request's exact base commit and rejects a mismatch; release-contract resolves a published release asset. Both are labelled separately and never merged. A missing or incompatible baseline is an explicit incomplete outcome. Publication The analysis stays unprivileged (contents: read, no secrets), and a separate default-branch workflow_run publisher with only actions: read and pull-requests: write verifies the producer run, resolves the pull request through the API, distinguishes the PR head SHA from the built merge commit, and renders the canonical report without running any analysis. Removed The 120-minute timeout and its ~100-minute rationale, the mandatory Bear capture, the projected compile databases, the hand-rolled non-atomic summary.json, the hard-coded 0/2/4 exit-code acceptance, and the practice of concatenating full review reports into $GITHUB_STEP_SUMMARY. The abicheck extraction config keeps its -std=c++17 override, now with the measured reason: C++11 and C++14 both fail to parse libstdc++ 13 under the supported CastXML 0.7.0. Two abicheck product bugs found while validating this are documented as known issues; they are owed by abicheck and are not papered over here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-collect.sh | 51 ++++ .ci-local/abicheck-diff.sh | 232 --------------- .ci-local/abicheck-inputs.sh | 126 +++++++++ .ci-local/abicheck-snapshots.sh | 48 ++++ .ci-local/abicheck.yml | 28 +- .github/workflows/abicheck-baseline.yml | 108 +++++++ .github/workflows/abicheck-report.yml | 180 ++++++++++++ .github/workflows/abicheck-shadow.yml | 78 ----- .github/workflows/ci-scripts-build.yml | 362 ++++++++++++++++++++++++ documentation/abicheck.md | 159 +++++++++++ 10 files changed, 1059 insertions(+), 313 deletions(-) create mode 100755 .ci-local/abicheck-collect.sh delete mode 100755 .ci-local/abicheck-diff.sh create mode 100755 .ci-local/abicheck-inputs.sh create mode 100755 .ci-local/abicheck-snapshots.sh create mode 100644 .github/workflows/abicheck-baseline.yml create mode 100644 .github/workflows/abicheck-report.yml delete mode 100644 .github/workflows/abicheck-shadow.yml create mode 100644 documentation/abicheck.md diff --git a/.ci-local/abicheck-collect.sh b/.ci-local/abicheck-collect.sh new file mode 100755 index 000000000..f51a5a079 --- /dev/null +++ b/.ci-local/abicheck-collect.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Collect the check reports this run produced and declare the set of checks +# it was supposed to produce. +# +# Every check is named explicitly by the caller as `=`. +# An empty path means "this check was expected but produced no report": it is +# recorded in the expected-target manifest and deliberately left absent from +# the report directory, so `abicheck aggregate` reports it as an unavailable +# target. A missing analysis is never turned into a clean result here, and +# no exit code is interpreted in this script. +set -eu + +DEST=${1:?usage: abicheck-collect.sh =...} +shift +mkdir -p "$DEST" + +MANIFEST="$DEST/expected-targets.json" +: > "$DEST/.collect-index" +for spec in "$@"; do + id=${spec%%=*} + path=${spec#*=} + [ -n "$id" ] || { echo "empty check id in '$spec'" >&2; exit 64; } + if [ -n "$path" ] && [ -f "$path" ]; then + # abicheck aggregate discovers reports by filename prefix. + safe=$(printf '%s' "$id" | tr -c 'A-Za-z0-9._-' '_') + cp "$path" "$DEST/abi-report-$safe.json" + printf '%s\tpresent\n' "$id" >> "$DEST/.collect-index" + else + printf '%s\tmissing\n' "$id" >> "$DEST/.collect-index" + echo "::warning::no report for check '$id'; it will aggregate as an unavailable target" + fi +done + +python3 - "$DEST/.collect-index" "$MANIFEST" <<'PY' +import json, sys + +index, out = sys.argv[1:3] +targets = [] +with open(index, encoding="utf-8") as fh: + for line in fh: + line = line.rstrip("\n") + if not line: + continue + check_id, _, _state = line.partition("\t") + targets.append({"id": check_id, "required": True}) + +with open(out, "w", encoding="utf-8") as fh: + json.dump({"targets": targets}, fh, indent=2, sort_keys=True) +print(f"declared {len(targets)} expected check(s) in {out}") +PY +rm -f "$DEST/.collect-index" diff --git a/.ci-local/abicheck-diff.sh b/.ci-local/abicheck-diff.sh deleted file mode 100755 index 1625794f1..000000000 --- a/.ci-local/abicheck-diff.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/bin/sh -# Advisory ABICheck scan. abi-diff.sh / ABICC remains authoritative. -set -eu - -OLD_REF=${1:-} -NEW_REF=${2:-HEAD} -ABICHECK=${ABICHECK:-abicheck} -JOBS=${ABICHECK_MAKE_JOBS:-2} -REPORT_ROOT=${ABICHECK_REPORT_ROOT:-compat_reports/abicheck} -RUN_ROOT=${RUNNER_TEMP:-${TMPDIR:-/tmp}}/pvxs-abicheck-${GITHUB_RUN_ID:-$$} - -explicit_old=1 -new_sha=$(git rev-parse "$NEW_REF^{commit}") -if [ -z "$OLD_REF" ]; then - explicit_old=0 - OLD_REF=$(git describe --tags --abbrev=0 "$new_sha") -fi -old_sha=$(git rev-parse "$OLD_REF^{commit}") -if [ "$explicit_old" -eq 0 ] && [ "$old_sha" = "$new_sha" ]; then - OLD_REF=$(git describe --tags --abbrev=0 "$new_sha^") - old_sha=$(git rev-parse "$OLD_REF^{commit}") -fi - -mkdir -p "$RUN_ROOT" "$REPORT_ROOT" -export HOME="$RUN_ROOT/home" -export XDG_CACHE_HOME="$RUN_ROOT/cache" -export TMPDIR="$RUN_ROOT/tmp" -mkdir -p "$HOME" "$XDG_CACHE_HOME" "$TMPDIR" - -EPICS_BASE=${EPICS_BASE:-} -if [ -z "$EPICS_BASE" ] && [ -f configure/RELEASE.local ]; then - EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' configure/RELEASE.local | tail -n 1) -fi -[ -n "$EPICS_BASE" ] && [ -d "$EPICS_BASE/include" ] || { - echo "EPICS_BASE include tree unavailable; use cue.py exec after prepare" >&2 - exit 64 -} -command -v bear >/dev/null || { echo "bear is required for complete build evidence" >&2; exit 64; } - -OLD_SRC="$RUN_ROOT/old" -NEW_SRC="$RUN_ROOT/new" -# git archive has no enclosing directory; extract each revision into its own -# fixed root so paths and build evidence remain side-specific. -mkdir -p "$OLD_SRC" "$NEW_SRC" -git archive "$old_sha" | tar -C "$OLD_SRC" -xf - -git archive "$new_sha" | tar -C "$NEW_SRC" -xf - - -prepare_build() { - src=$1 - [ -f configure/RELEASE.local ] && cp configure/RELEASE.local "$src/configure/" - [ -f configure/CONFIG_SITE.local ] && cp configure/CONFIG_SITE.local "$src/configure/" - sed -i -e "s|\$(TOP)|$(pwd)|g" -e 's|-Werror||g' "$src"/configure/*.local 2>/dev/null || true - bear --output "$src/compile_commands.json" -- \ - make -C "$src" CROSS_COMPILER_TARGET_ARCHS= OPT_CFLAGS='-g -Og' OPT_CXXFLAGS='-g -Og' ioc -j"$JOBS" -} - -prepare_build "$OLD_SRC" -prepare_build "$NEW_SRC" - -stage_headers() { - src=$1 - target=$2 - out=$3 - public="$out/public" - support="$out/support" - if [ "$target" = libpvxs ]; then - source_headers="$src/src/pvxs" - generated_headers="$src/src/O.Common/pvxs" - [ -d "$source_headers" ] || { echo "missing PVXS public header root $source_headers" >&2; return 64; } - [ -f "$generated_headers/versionNum.h" ] || { echo "missing generated version header" >&2; return 64; } - headers=$(find "$source_headers" -maxdepth 1 -type f -name '*.h' -print | LC_ALL=C sort) - [ -n "$headers" ] || { echo "no PVXS public headers below $source_headers" >&2; return 64; } - mkdir -p "$public/pvxs" || return 64 - while IFS= read -r header_src; do - cp "$header_src" "$public/pvxs/$(basename "$header_src")" || return 64 - done <&2 - return 64 - } - printf '%s\n' "$matches" -} - -old_id=$(printf '%s' "$old_sha" | cut -c1-12) -new_id=$(printf '%s' "$new_sha" | cut -c1-12) -status_file="$REPORT_ROOT/summary.json" -python3 - "$status_file" "$OLD_REF" "$old_sha" "$NEW_REF" "$new_sha" <<'PY' -import json, sys -path, old_ref, old_sha, new_ref, new_sha = sys.argv[1:] -with open(path, "w") as output: - output.write(json.dumps({ - "old_ref": old_ref, "old_sha": old_sha, - "new_ref": new_ref, "new_sha": new_sha, - }, separators=(",", ":"))[:-1]) - output.write(',"targets":[') -PY -first=1 -overall=0 - -run_one() { - target=$1 - oldso=$(find_dso "$OLD_SRC" "$target") - newso=$(find_dso "$NEW_SRC" "$target") - [ -n "$oldso" ] && [ -n "$newso" ] || return 64 - old_headers="$RUN_ROOT/headers-old-$target" - new_headers="$RUN_ROOT/headers-new-$target" - stage_headers "$OLD_SRC" "$target" "$old_headers" || return $? - stage_headers "$NEW_SRC" "$target" "$new_headers" || return $? - old_db="$OLD_SRC/compile_commands.$target.json" - new_db="$NEW_SRC/compile_commands.$target.json" - project_compile_db "$OLD_SRC" "$target" "$old_db" || return $? - project_compile_db "$NEW_SRC" "$target" "$new_db" || return $? - base="$RUN_ROOT/reports/${target}_${old_id}_to_${new_id}" - published="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" - mkdir -p "$(dirname "$base")" || return 64 - if "$ABICHECK" compare "$oldso" "$newso" \ - --version "old=$old_sha" --version "new=$new_sha" \ - --header "old=$old_headers/public" --header "new=$new_headers/public" \ - --include "old:pvxs=$old_headers/public" --include "new:pvxs=$new_headers/public" \ - --include "old:pvxs-config=$old_headers/support" --include "new:pvxs-config=$new_headers/support" \ - --include "old:pvxs-core=$RUN_ROOT/headers-old-libpvxs/public" --include "new:pvxs-core=$RUN_ROOT/headers-new-libpvxs/public" \ - --include "old:pvxs-core-config=$RUN_ROOT/headers-old-libpvxs/support" --include "new:pvxs-core-config=$RUN_ROOT/headers-new-libpvxs/support" \ - --include "old:epics=$EPICS_BASE/include" --include "new:epics=$EPICS_BASE/include" \ - --include "old:epics-os=$EPICS_BASE/include/os/Linux" --include "new:epics-os=$EPICS_BASE/include/os/Linux" \ - --include "old:epics-gcc=$EPICS_BASE/include/compiler/gcc" --include "new:epics-gcc=$EPICS_BASE/include/compiler/gcc" \ - --depth build \ - --build-info "old=$old_db" --build-info "new=$new_db" \ - --config "$PWD/.ci-local/abicheck.yml" \ - -o "review=$base.md" -o "json=$base.json" - then - rc=0 - else - rc=$? - fi - if [ ! -s "$base.json" ] || [ ! -s "$base.md" ]; then - echo "missing comparison reports for $target" >&2 - rc=64 - elif ! python3 - "$base.json" <<'PY' -import json, sys -try: - report = json.load(open(sys.argv[1])) -except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"invalid JSON report: {exc}") -if not isinstance(report, dict) or not isinstance(report.get("verdict"), str): - raise SystemExit("missing comparison verdict") -assurance = report.get("analysis_assurance") -if not isinstance(assurance, dict) or assurance.get("status") != "complete": - raise SystemExit("analysis assurance is not complete") -if report.get("analysis_assurance_exit_contribution") != 0: - raise SystemExit("analysis assurance gate is inconsistent") -PY - then - echo "invalid or incomplete analysis assurance for $target" >&2 - rc=1 - elif cp "$base.json" "$published.json" && cp "$base.md" "$published.md"; then - : > "$RUN_ROOT/$target.report-ready" - else - echo "failed to publish comparison reports for $target" >&2 - rm -f "$published.json" "$published.md" "$RUN_ROOT/$target.report-ready" - rc=64 - fi - printf '%s\n' "$rc" > "$RUN_ROOT/$target.exit-code" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$base.md" ]; then cat "$base.md" >> "$GITHUB_STEP_SUMMARY"; fi - case "$rc" in 0|2|4) return 0;; *) return "$rc";; esac -} - -append_target() { - target=$1 - rc=$2 - base="$REPORT_ROOT/${target}_${old_id}_to_${new_id}" - [ "$first" -eq 1 ] || printf ',' >> "$status_file" - first=0 - if [ -f "$RUN_ROOT/$target.report-ready" ] && [ -f "$base.json" ]; then - printf '{"target":"%s","exit_code":%s,"report":"%s.json"}' "$target" "$rc" "$(basename "$base")" >> "$status_file" - else - printf '{"target":"%s","exit_code":%s,"report":null}' "$target" "$rc" >> "$status_file" - fi -} - -for target in libpvxs libpvxsIoc; do - if run_one "$target"; then - rc=0 - else - rc=$? - fi - if [ -f "$RUN_ROOT/$target.exit-code" ]; then - rc=$(cat "$RUN_ROOT/$target.exit-code") - fi - append_target "$target" "$rc" - case "$rc" in 0|2|4) ;; *) overall=1;; esac -done -printf '],"integration_health":%s}\n' "$overall" >> "$status_file" -exit "$overall" diff --git a/.ci-local/abicheck-inputs.sh b/.ci-local/abicheck-inputs.sh new file mode 100755 index 000000000..fa1ee2790 --- /dev/null +++ b/.ci-local/abicheck-inputs.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# Resolve the inputs an ABI capture needs from an already-completed PVXS +# build. This script performs no build, no analysis and no reporting: it +# only names files the normal `cue.py build` installed, validates their +# shape, and prints `key=value` lines (also appended to $GITHUB_OUTPUT when +# running under GitHub Actions). +# +# Public-surface ownership (see .github/workflows/abicheck-analyse.yml): +# +# libpvxs owns every installed include/pvxs/*.h except iochooks.h +# libpvxsIoc owns include/pvxs/iochooks.h only; the core headers are +# include-only context for it. +# +# An include search root is context for the C++ parser, not a declaration +# that the component must export everything declared under it. +set -eu + +TOP=${1:-$PWD} +cd "$TOP" + +fail() { echo "abicheck-inputs: $*" >&2; exit 64; } + +# EPICS_BASE: use the already-prepared dependency. configure/RELEASE.local +# is where cue.py records it; we read it, we never rewrite it, and we never +# move HOME to rediscover it. +if [ -z "${EPICS_BASE:-}" ] && [ -f configure/RELEASE.local ]; then + EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' configure/RELEASE.local | tail -n 1) +fi +[ -n "${EPICS_BASE:-}" ] || fail "EPICS_BASE is not set and configure/RELEASE.local does not name it" +[ -d "$EPICS_BASE/include" ] || fail "EPICS_BASE=$EPICS_BASE has no include/ tree; run 'cue.py prepare' first" + +ARCH=${EPICS_HOST_ARCH:-} +if [ -z "$ARCH" ]; then + [ -x "$EPICS_BASE/startup/EpicsHostArch" ] || fail "cannot determine EPICS_HOST_ARCH" + ARCH=$("$EPICS_BASE/startup/EpicsHostArch") +fi +case "$ARCH" in + linux-*) ;; + *) fail "this capture is declared for a native Linux host arch, got '$ARCH'" ;; +esac + +LIBDIR="$TOP/lib/$ARCH" +[ -d "$LIBDIR" ] || fail "no installed library directory $LIBDIR; was 'cue.py build' run?" + +# Resolve exactly one real shared object per component. Symlinks (the +# unversioned/SONAME aliases) are deliberately excluded so they are not +# analysed as duplicate components, and static archives are never eligible. +resolve_dso() { + _name=$1 + _matches=$(find "$LIBDIR" -maxdepth 1 -type f -name "$_name.so.*" -print | LC_ALL=C sort) + _count=$(printf '%s\n' "$_matches" | sed '/^$/d' | wc -l) + [ "$_count" -eq 1 ] || fail "expected exactly one $_name shared object in $LIBDIR, found $_count" + _so=$_matches + head -c 4 "$_so" | grep -q 'ELF' || fail "$_so is not an ELF object" + _hdr=$(readelf -h "$_so" 2>/dev/null) || fail "cannot read ELF header of $_so" + printf '%s\n' "$_hdr" | grep -q 'Type:[[:space:]]*DYN' || fail "$_so is not a shared object (DYN)" + printf '%s\n' "$_so" +} + +LIBPVXS=$(resolve_dso libpvxs) +LIBPVXSIOC=$(resolve_dso libpvxsIoc) + +MACHINE=$(readelf -h "$LIBPVXS" | sed -n -E 's/^[[:space:]]*Machine:[[:space:]]*//p') +IOC_MACHINE=$(readelf -h "$LIBPVXSIOC" | sed -n -E 's/^[[:space:]]*Machine:[[:space:]]*//p') +[ "$MACHINE" = "$IOC_MACHINE" ] || fail "libpvxs ($MACHINE) and libpvxsIoc ($IOC_MACHINE) disagree on target machine" + +INCROOT="$TOP/include" +[ -d "$INCROOT/pvxs" ] || fail "no installed header tree $INCROOT/pvxs" +IOC_HEADER="$INCROOT/pvxs/iochooks.h" +[ -f "$IOC_HEADER" ] || fail "libpvxsIoc's only public header $IOC_HEADER was not installed" + +# Generated public headers (versionNum.h) are installed alongside the +# checked-in ones; both belong to libpvxs' owned surface. +[ -f "$INCROOT/pvxs/versionNum.h" ] || fail "generated public header versionNum.h was not installed" + +CORE_HEADERS=$(find "$INCROOT/pvxs" -maxdepth 1 -type f -name '*.h' ! -name 'iochooks.h' -print | LC_ALL=C sort | tr '\n' ' ') +[ -n "$CORE_HEADERS" ] || fail "no installed core PVXS headers below $INCROOT/pvxs" +CORE_HEADERS=${CORE_HEADERS% } + +# Include-only context shared by both components. The EPICS dependency +# needs its normal include root plus the OS and compiler sub-roots. +INCLUDES="$INCROOT $EPICS_BASE/include $EPICS_BASE/include/os/Linux $EPICS_BASE/include/compiler/gcc" +for d in $INCLUDES; do + [ -d "$d" ] || fail "include root $d does not exist" +done + +emit() { + printf '%s=%s\n' "$1" "$2" + [ -z "${GITHUB_OUTPUT:-}" ] || printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT" +} + +emit host-arch "$ARCH" +emit machine "$MACHINE" +emit epics-base "$EPICS_BASE" +emit libpvxs "$LIBPVXS" +emit libpvxsioc "$LIBPVXSIOC" +emit core-headers "$CORE_HEADERS" +emit ioc-headers "$IOC_HEADER" +emit includes "$INCLUDES" + +# The capture Action (abicheck actions/baseline) takes one JSON array +# describing every library to dump. Building it here keeps the ownership +# declaration in one place instead of duplicating the header lists in YAML. +LIBRARIES=$(CORE_HEADERS="$CORE_HEADERS" IOC_HEADER="$IOC_HEADER" \ + INCLUDES="$INCLUDES" LIBPVXS="$LIBPVXS" LIBPVXSIOC="$LIBPVXSIOC" \ + python3 -c ' +import json, os +print(json.dumps([ + { + # libpvxs owns every installed public header except the IOC one. + "name": "libpvxs", + "artifact": os.environ["LIBPVXS"], + "header": os.environ["CORE_HEADERS"], + "include": os.environ["INCLUDES"], + }, + { + # libpvxsIoc owns exactly one header; the core and EPICS headers + # are include-only context, not an export obligation. + "name": "libpvxsIoc", + "artifact": os.environ["LIBPVXSIOC"], + "header": os.environ["IOC_HEADER"], + "include": os.environ["INCLUDES"], + }, +], separators=(",", ":"))) +') +emit libraries "$LIBRARIES" diff --git a/.ci-local/abicheck-snapshots.sh b/.ci-local/abicheck-snapshots.sh new file mode 100755 index 000000000..25a980ce2 --- /dev/null +++ b/.ci-local/abicheck-snapshots.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Name the candidate snapshot each component's comparison must use. +# +# The capture step wrote a baseline-set: one snapshot per library plus a +# manifest.json recording their identities and digests. This reads that +# manifest -- it does not guess filenames, and it does not pick whichever +# file happens to sort first. +set -eu + +DIR=${1:?usage: abicheck-snapshots.sh } +MANIFEST="$DIR/manifest.json" +[ -f "$MANIFEST" ] || { echo "no manifest.json in $DIR" >&2; exit 64; } + +DIR="$DIR" python3 - "$MANIFEST" <<'PY' +import json, os, sys + +manifest = json.load(open(sys.argv[1], encoding="utf-8")) +root = os.path.realpath(os.environ["DIR"]) +wanted = {"libpvxs": "libpvxs", "libpvxsIoc": "libpvxsioc"} + +artifacts = manifest.get("artifacts") or manifest.get("libraries") or [] +found = {} +for entry in artifacts: + name = entry.get("library") or entry.get("name") + if name not in wanted: + continue + rel = entry.get("artifact") or entry.get("snapshot") or entry.get("path") + if not rel: + raise SystemExit(f"manifest entry for {name} names no snapshot file") + path = os.path.realpath(os.path.join(root, rel)) + # The manifest is produced in the same job, but treat it as data anyway. + if os.path.commonpath([root, path]) != root: + raise SystemExit(f"manifest entry for {name} escapes the baseline-set") + if not os.path.isfile(path): + raise SystemExit(f"snapshot for {name} is missing: {path}") + found[name] = path + +missing = sorted(set(wanted) - set(found)) +if missing: + raise SystemExit("baseline-set has no snapshot for: " + ", ".join(missing)) + +lines = [f"{wanted[name]}={path}" for name, path in sorted(found.items())] +out = os.environ.get("GITHUB_OUTPUT") +if out: + with open(out, "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") +print("\n".join(lines)) +PY diff --git a/.ci-local/abicheck.yml b/.ci-local/abicheck.yml index 597a71674..b91449ac5 100644 --- a/.ci-local/abicheck.yml +++ b/.ci-local/abicheck.yml @@ -1,8 +1,30 @@ -# CastXML packaged on the GitHub runner cannot parse libstdc++ with PVXS's -# default C++11 mode. This is deliberately recorded as extraction context; -# the libraries themselves keep PVXS's normal build flags. +# Extraction context for the advisory abicheck shadow check. +# +# This describes how the PUBLIC HEADERS are parsed for ABI/API extraction. +# It does not change how PVXS itself is built: the libraries analysed are +# exactly the ones `cue.py build` produced, with PVXS's normal flags. compile: options: + # PVXS's supported consumer language mode is C++11, and that is what its + # own build uses. Parsing the public headers in C++11 or C++14 against + # the runner's libstdc++ 13 is not possible with the supported CastXML + # (0.7.0, Clang 20): libstdc++ 13's fails with + # "statement not allowed in constexpr function" in both modes. Measured + # on 2026-09-16; a conda-forge GCC 16 libstdc++ is worse (it needs C++20 + # to parse its own ). + # + # This is therefore a deliberate, disclosed deviation of the extraction + # context from the consumer language mode, not a silent one, and it is + # applied identically to libpvxs and libpvxsIoc so the two components and + # both sides of every comparison are interpreted the same way. Remove it + # as soon as a toolchain that parses the headers in C++11 is available. - -std=c++17 + # + # Deliberately NOT set here: PVXS_API_BUILDING (a library-build-only + # macro) and PVXS_ENABLE_EXPERT_API (an opt-in expert surface). The + # headers are parsed the way an ordinary consumer sees them. assurance: + # A comparison that could not complete must be reported as incomplete, not + # as a clean result. The shadow gate stays advisory; this controls what + # the analysis is allowed to claim, not whether CI goes red. require_complete: true diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml new file mode 100644 index 000000000..18789ff85 --- /dev/null +++ b/.github/workflows/abicheck-baseline.yml @@ -0,0 +1,108 @@ +# Publish the release-contract ABI baseline-set for the selected profile. +# +# The snapshots themselves are not produced here: they are the same capture +# ci-scripts-build.yml already performed for the tagged build. This workflow +# packages that capture and attaches it to the release, so later pull +# requests have something to compare against without rebuilding history. +# +# Trust boundary: it publishes ONLY from a tag build of this repository -- +# never from a pull request, and never from a fork. A snapshot produced by +# contributor code can never reach the release-contract channel. +# +# The accepted-main channel needs nothing here. A pull request resolves it +# by downloading the ci-scripts-build.yml run for its own base commit, keyed +# by that exact SHA (see the `Stage the accepted-main baseline` step there), +# and check-target rejects a baseline whose recorded project_ref is not that +# commit. + +name: ABI baseline + +on: + workflow_run: + workflows: ["PVXS EPICS"] + types: [completed] + # One-time bootstrap for a historical release that predates this check: + # build that tag once by hand (workflow_dispatch on PVXS EPICS at the tag), + # then publish the resulting capture here. The result is retained as a + # release asset; it is never rebuilt per pull request, and a missing one is + # reported as a missing baseline rather than silently replaced. + workflow_dispatch: + inputs: + run-id: + description: 'PVXS EPICS run id whose captured snapshots to publish.' + required: true + type: string + tag: + description: 'Release tag to attach the baseline-set to.' + required: true + type: string + +permissions: + contents: read + +env: + ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent + ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + +jobs: + publish: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'v')) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + # The single write scope in this integration, and only here: attaching + # the packaged baseline-set to an existing release of this repository. + contents: write + steps: + - name: Resolve the producer run + id: source + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ inputs.run-id || github.event.workflow_run.id }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + run_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") + test "$(jq -r '.repository.full_name' <<<"$run_json")" = "$GITHUB_REPOSITORY" + test "$(jq -r '.path' <<<"$run_json")" = ".github/workflows/ci-scripts-build.yml" + event=$(jq -r '.event' <<<"$run_json") + case "$event" in + push|workflow_dispatch) ;; + *) echo "::error::run $RUN_ID was triggered by '$event'; only a tag build may publish a baseline"; exit 1 ;; + esac + head_sha=$(jq -r '.head_sha' <<<"$run_json") + tag=${TAG:-$(jq -r '.head_branch' <<<"$run_json")} + # The tag must exist and must point at the commit that was built. + tag_sha=$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq '.sha') + test "$tag_sha" = "$head_sha" + { echo "run-id=$RUN_ID"; echo "tag=$tag"; echo "sha=$head_sha"; } >> "$GITHUB_OUTPUT" + + - uses: actions/download-artifact@v7 + with: + name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/baseline-set + run-id: ${{ steps.source.outputs.run-id }} + github-token: ${{ github.token }} + + - name: Package the baseline-set + id: stage + uses: abicheck/abicheck/actions/stage-baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + baseline-path: ${{ runner.temp }}/baseline-set + asset-name-template: 'abicheck-baseline-{profile}.tar.zst' + profile: ${{ env.ABICHECK_PROFILE }} + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.source.outputs.tag }} + ASSET: ${{ steps.stage.outputs.archive-path }} + run: | + set -euo pipefail + test -s "$ASSET" + gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml new file mode 100644 index 000000000..bfab37bbe --- /dev/null +++ b/.github/workflows/abicheck-report.yml @@ -0,0 +1,180 @@ +# Trusted publisher for the advisory ABI/API check. +# +# The analysis that produces the reports runs unprivileged, in +# ci-scripts-build.yml, under the contributor's own pull request -- including +# pull requests from forks. That job has `contents: read` and no secrets, so +# it cannot comment, and it must not be given the ability to. +# +# This workflow is the other half. It runs from the repository's default +# branch, on workflow_run, with only the two permissions a comment actually +# needs. It never checks out, installs, imports or executes anything the +# pull request produced: it downloads that run's report artifact, treats +# every byte of it as untrusted data, and renders it with reviewed, +# pinned publisher code. +# +# It does not make the contributor's reports trusted evidence. The reports +# say where they came from and how complete they are, and that is preserved. +# What this workflow enforces is who the result may be delivered to and what +# is allowed to execute while delivering it. +# +# DEPLOYMENT PREREQUISITE: workflow_run only ever runs the copy of this file +# on the default branch. Until a maintainer has merged it there, nothing in +# a pull request -- including the pull request that adds this file -- will +# publish a comment. That is by design and must not be worked around. + +name: ABI/API report + +on: + workflow_run: + workflows: ["PVXS EPICS"] + types: [completed] + +# Only what publishing a comment needs. No contents: write, no packages, +# no id-token, no secrets. +permissions: + actions: read + pull-requests: write + +concurrency: + # One in-flight publication per producer run. Superseded runs are NOT + # cancelled: ordering is enforced by the publisher's own freshness check + # against what is already on the pull request, because cancellation alone + # cannot stop an older run that has already started from finishing last. + group: abicheck-report-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + publish: + # Only a pull-request-triggered producer run has a pull request to + # report to. A push/tag run publishes baselines instead (see + # abicheck-baseline.yml) and is ignored here. + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent + REPORTS: ${{ runner.temp }}/abicheck-reports + steps: + # ── 1. Establish who this run is, and who it is allowed to talk to ── + # + # Everything below comes from the GitHub API, never from the artifact: + # the artifact is contributor-controlled and may claim any repository, + # pull request or SHA it likes. + - name: Resolve and verify the producer run + id: source + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + + run_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") + + # Repository, workflow identity and event must all be what we expect. + test "$(jq -r '.repository.full_name' <<<"$run_json")" = "$GITHUB_REPOSITORY" + test "$(jq -r '.path' <<<"$run_json")" = ".github/workflows/ci-scripts-build.yml" + test "$(jq -r '.event' <<<"$run_json")" = "pull_request" + + attempt=$(jq -r '.run_attempt' <<<"$run_json") + head_sha=$(jq -r '.head_sha' <<<"$run_json") + conclusion=$(jq -r '.conclusion' <<<"$run_json") + + # Resolve the pull request through the API, by the producer run's own + # head SHA. Exactly one open pull request must match; anything else + # is ambiguous and fails visibly rather than guessing a recipient. + prs=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha/pulls" \ + --jq '[.[] | select(.state == "open") | .number]') + count=$(jq 'length' <<<"$prs") + if [ "$count" != "1" ]; then + echo "::error::cannot unambiguously associate run $RUN_ID (head $head_sha) with a pull request: matched $count" + exit 1 + fi + pr=$(jq -r '.[0]' <<<"$prs") + + pr_json=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr") + pr_head_sha=$(jq -r '.head.sha' <<<"$pr_json") + + # The PR head SHA and the SHA that was actually built are different + # things: for a pull_request event the producer builds the merge + # commit. Both are reported; neither is assumed to be the other. + # What is verified is that the merge commit really descends from the + # current PR head. + if [ "$head_sha" != "$pr_head_sha" ]; then + parents=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha" --jq '[.parents[].sha]') + if ! jq -e --arg s "$pr_head_sha" 'index($s)' >/dev/null <<<"$parents"; then + echo "::error::built commit $head_sha is not a merge of the current pull request head $pr_head_sha (the pull request moved); refusing to publish a stale result" + exit 1 + fi + fi + + { + echo "pr=$pr" + echo "run-id=$RUN_ID" + echo "run-attempt=$attempt" + echo "build-sha=$head_sha" + echo "pr-head-sha=$pr_head_sha" + echo "conclusion=$conclusion" + } >> "$GITHUB_OUTPUT" + + # ── 2. Fetch the reports from that exact run ──────────────────────── + - name: Download the report artifact + id: artifact + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: abicheck-reports-${{ env.ABICHECK_PROFILE }} + path: ${{ env.REPORTS }} + run-id: ${{ steps.source.outputs.run-id }} + github-token: ${{ github.token }} + + # ── 3. Publish ───────────────────────────────────────────────────── + # + # The publisher installs nothing but abicheck itself, runs no compiler, + # no build query and no analysis, and renders the aggregate document the + # producer already wrote. Two components, two baseline channels, one + # comment. + # + # NOTE: this step is pending abicheck's `actions/report`, which does not + # exist on abicheck main yet. See documentation/abicheck.md for the + # dependency and the exact revision this workflow was tested against. + # Do not point it at a mutable ref. + - name: Publish the ABI/API report + if: ${{ steps.artifact.outcome == 'success' }} + uses: abicheck/abicheck/actions/report@PENDING_ABICHECK_REPORT_ACTION_SHA + with: + report: ${{ env.REPORTS }}/aggregate.json + repository: ${{ github.repository }} + pr-number: ${{ steps.source.outputs.pr }} + sha: ${{ steps.source.outputs.build-sha }} + comment-identity: abicheck/${{ env.ABICHECK_PROFILE }} + run-label: >- + run ${{ steps.source.outputs.run-id }} + (attempt ${{ steps.source.outputs.run-attempt }}) + report-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ steps.source.outputs.run-id }} + detail: standard + on: changes + job-summary: 'true' + github-token: ${{ github.token }} + + # A failed or missing analysis is reported as a failed or missing + # analysis, on the pull request, where the association was established. + # It is never allowed to look like a clean compatibility result, and it + # is never inferred from the producer run's overall conclusion -- an + # unrelated red test leg does not mean the ABI check failed. + - name: Publish an incomplete-analysis notice + if: ${{ steps.artifact.outcome != 'success' }} + uses: abicheck/abicheck/actions/report@PENDING_ABICHECK_REPORT_ACTION_SHA + with: + report: '' + incomplete-reason: >- + No ABI/API report artifact was produced by run + ${{ steps.source.outputs.run-id }}. The comparison did not complete; + this is not a statement that no changes were found. + repository: ${{ github.repository }} + pr-number: ${{ steps.source.outputs.pr }} + sha: ${{ steps.source.outputs.build-sha }} + comment-identity: abicheck/${{ env.ABICHECK_PROFILE }} + run-label: run ${{ steps.source.outputs.run-id }} + report-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ steps.source.outputs.run-id }} + on: always + github-token: ${{ github.token }} diff --git a/.github/workflows/abicheck-shadow.yml b/.github/workflows/abicheck-shadow.yml deleted file mode 100644 index 82d2dda7d..000000000 --- a/.github/workflows/abicheck-shadow.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: ABICheck shadow ABI scan - -on: - pull_request: - push: - branches: [master] - tags: ['*'] - workflow_dispatch: - -concurrency: - group: abicheck-shadow-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# The scanner is an advisory, parallel signal. It needs no PR comments, no -# status write access, and no repository secrets. -permissions: - contents: read - -jobs: - abicheck-shadow: - runs-on: ubuntu-latest - # An L2 build-evidence scan runs two revisions of both public DSOs. - # Keep this above the measured ~100-minute worst case, not at 45 minutes. - timeout-minutes: 120 - env: - SETUP_PATH: .ci-local - SET: defaults - CMP: gcc - BCFG: default - BASE: "7.0" - _PVXS_ABORT_ON_CRIT: 1 - PVXS_LOG: pvxs.*=WARN - ABICHECK_MAKE_JOBS: "2" - ABICHECK_REF: fb423dfd62c267b0db61739941f2d35ee2dacb16 - - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: "0" - submodules: true - persist-credentials: false - - - name: Install scanner tools - shell: bash - run: | - set -euxo pipefail - sudo apt-get update - sudo apt-get -y install libreadline-dev libevent-dev cmake bear - /usr/share/miniconda/bin/conda install -y -c conda-forge castxml - VENV="$RUNNER_TEMP/abicheck-venv" - python -m venv "$VENV" - "$VENV/bin/pip" install --disable-pip-version-check \ - "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" - echo "/usr/share/miniconda/bin" >> "$GITHUB_PATH" - echo "$VENV/bin" >> "$GITHUB_PATH" - echo "ABICHECK=$VENV/bin/abicheck" >> "$GITHUB_ENV" - - - name: Verify scanner tools - shell: bash - run: | - set -euxo pipefail - castxml --version - "$ABICHECK" --version - - - name: Prepare EPICS dependencies - run: python .ci/cue.py prepare - - - name: Run L2 build-evidence shadow scan - run: python .ci/cue.py exec ./.ci-local/abicheck-diff.sh - - - name: Upload ABICheck reports - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - retention-days: 30 - name: abicheck-shadow - path: compat_reports/abicheck - if-no-files-found: error diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index beefedbd4..e5db892a5 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -17,11 +17,24 @@ on: pull_request: workflow_dispatch: +# The analysis side is unprivileged on purpose. A contributor's fork PR +# builds and analyses here with no write access and no secrets; publishing +# the result to the PR is a separate, trusted workflow_run job +# (.github/workflows/abicheck-report.yml). +permissions: + contents: read + env: SETUP_PATH: .ci-local CMP: gcc _PVXS_ABORT_ON_CRIT: 1 PVXS_LOG: pvxs.*=WARN + # Baseline identity is more than a version number: this names the exact + # build arrangement the captured snapshots and every baseline they are + # compared against must share -- native Linux x86_64, GCC, default + # (shared) configuration, EPICS Base 7.0, and PVXS's own bundled + # libevent rather than the system one. + ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent jobs: native: @@ -49,6 +62,12 @@ jobs: base: "7.0" extra: "CMD_CPPFLAGS=-Werror" doc: 1 + # The one configuration that additionally captures ABI/API + # evidence for the advisory abicheck shadow check. Native + # Linux, GCC, shared libraries, bundled libevent -- the + # profile the published baselines are built for. Every other + # leg is untouched. + abicheck: 1 - name: Native Linux (libc++ debug) os: ubuntu-latest @@ -167,6 +186,7 @@ jobs: - name: Build libevent run: python .ci/cue.py exec python .ci-local/libevent.py - name: Build main module + id: build-main run: python .ci/cue.py build - name: Generate Docs if: matrix.doc @@ -216,6 +236,348 @@ jobs: path: '**/O.*/*.tap' if-no-files-found: ignore + # ── ABI/API evidence capture (advisory; ABICC stays authoritative) ──── + # + # This reuses the build this job has already done. There is no second + # build, no second dependency preparation and no source-tree input: the + # capture reads the headers in include/pvxs and the shared objects in + # lib/$EPICS_HOST_ARCH that `cue.py build` installed, plus the EPICS + # Base include roots `cue.py prepare` had already set up. + # + # It runs last, after this job's own tests, so nothing else that needs + # this checkout is disturbed, and it runs even when those tests failed + # (an unrelated red test must not hide an ABI finding) as long as the + # build itself produced an installation to look at. + # + # Depth is L2 -- exported symbols and debug info (L0/L1) plus the public + # header AST. That is a deliberate reduction from the previous + # integration, which rebuilt both revisions under Bear to reach L3: + # build-drift coverage is NOT part of this check any more. + - name: Resolve ABI capture inputs + id: abi-inputs + if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' }} + run: ./.ci-local/abicheck-inputs.sh + + - name: Capture ABI snapshots (libpvxs, libpvxsIoc) + id: abi-capture + if: ${{ always() && steps.abi-inputs.outcome == 'success' }} + uses: abicheck/abicheck/actions/baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + libraries: ${{ steps.abi-inputs.outputs.libraries }} + output-dir: ${{ runner.temp }}/abicheck-candidate + # The revision actually built and analysed here. For a pull_request + # this is the merge commit, NOT the PR head SHA -- the publisher + # reports both, and never conflates them. + project-ref: ${{ github.sha }} + profile: ${{ env.ABICHECK_PROFILE }} + depth: headers + build-config: .ci-local/abicheck.yml + baseline-generation: '1' + generator-action-ref: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + # A libpvxs snapshot is ~124 MB of JSON at this depth (measured); + # two of them per run is worth compressing before it becomes CI + # artifact storage. The decoded content is identical. + snapshot-compression: zstd + + - name: Record analysis context + id: abi-context + if: ${{ always() && steps.abi-capture.outcome == 'success' }} + env: + OUT: ${{ runner.temp }}/abicheck-candidate/analysis-context.json + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -eu + # Diagnostics only. Everything here is produced by an unprivileged + # job, so the trusted publisher re-derives the repository, run and + # pull request from the GitHub API and never trusts these values. + python3 - <<'EOF' + import json, os + doc = { + "note": "untrusted: produced by the analysis job, re-derive via the API", + "repository": os.environ["GITHUB_REPOSITORY"], + "run_id": os.environ["GITHUB_RUN_ID"], + "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], + "workflow_ref": os.environ["GITHUB_WORKFLOW_REF"], + "event": os.environ["GITHUB_EVENT_NAME"], + "build_sha": os.environ["GITHUB_SHA"], + "pr_number": os.environ.get("PR_NUMBER") or None, + "pr_head_sha": os.environ.get("PR_HEAD_SHA") or None, + "pr_base_sha": os.environ.get("PR_BASE_SHA") or None, + "pr_base_ref": os.environ.get("PR_BASE_REF") or None, + "profile": os.environ["ABICHECK_PROFILE"], + "abicheck_ref": "3737f9f960ae14a7b24dbb6e9b686e49fb563673", + } + with open(os.environ["OUT"], "w") as f: + json.dump(doc, f, indent=2, sort_keys=True) + EOF + + - name: Upload ABI snapshots + if: ${{ always() && steps.abi-capture.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + retention-days: 30 + name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/abicheck-candidate + if-no-files-found: error + + - name: Report a failed ABI capture + if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' && steps.abi-capture.outcome != 'success' }} + run: | + set -eu + mkdir -p "$RUNNER_TEMP/abicheck-candidate-failure" + printf '%s\n' \ + '{"state":"capture-failed",' \ + ' "inputs_outcome":"${{ steps.abi-inputs.outcome }}",' \ + ' "capture_outcome":"${{ steps.abi-capture.outcome }}"}' \ + > "$RUNNER_TEMP/abicheck-candidate-failure/state.json" + echo "::warning::ABI capture did not complete; downstream comparison will report an incomplete analysis, not a clean result." + + - name: Upload ABI capture failure marker + if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' && steps.abi-capture.outcome != 'success' }} + uses: actions/upload-artifact@v7 + with: + retention-days: 30 + name: abicheck-candidate-failure-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/abicheck-candidate-failure + if-no-files-found: error + + # ── ABI/API comparison (advisory; ABICC stays authoritative) ──────────── + # + # Consumes the snapshots the selected native leg captured in THIS run. + # It never extracts anything itself: both operands are snapshots, so no + # compiler, no castxml and no EPICS checkout are needed here. + # + # `needs: native` waits for the whole matrix -- GitHub cannot depend on a + # single leg -- but `if: always()` means an unrelated red leg (a flaky + # RTEMS test, an OSX build) never suppresses an available ABI finding. + abicheck: + name: ABI/API check (advisory) + needs: native + if: ${{ always() }} + runs-on: ubuntu-latest + # Measured: ~4 min for both snapshot-to-snapshot comparisons plus + # staging, on a warm runner. Bounded well above that, but nowhere near + # wide enough to let a hung step masquerade as a narrowed scope. + timeout-minutes: 25 + permissions: + contents: read + # Needed only to list this repository's own default-branch runs when + # resolving the accepted-main baseline. No write scope, no secrets. + actions: read + env: + ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + REPORT_DIR: ${{ github.workspace }}/abicheck-reports + CANDIDATE_DIR: ${{ github.workspace }}/abicheck-candidate + BASELINE_RELEASE_DIR: ${{ github.workspace }}/abicheck-baseline-release + BASELINE_MAIN_DIR: ${{ github.workspace }}/abicheck-baseline-main + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Download candidate snapshots + id: candidate + # Narrow, deliberate: a missing artifact is a real outcome this job + # must report as an incomplete analysis, not a step that silently + # succeeds. Every later step branches on this outcome explicitly. + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} + path: ${{ env.CANDIDATE_DIR }} + + - name: Record a missing candidate capture + if: ${{ steps.candidate.outcome != 'success' }} + run: | + set -eu + mkdir -p "$REPORT_DIR" + printf '%s\n' '{"state":"candidate-capture-unavailable"}' \ + > "$REPORT_DIR/abicheck-incomplete.json" + echo "::warning::No ABI snapshots were captured for profile $ABICHECK_PROFILE; reporting an incomplete analysis." + + # ── Baseline staging ──────────────────────────────────────────────── + # Two distinct questions, two distinct channels, never merged: + # accepted-main what did THIS pull request introduce? + # release-contract what changed since the selected supported release? + + - name: Stage the release-contract baseline + if: ${{ steps.candidate.outcome == 'success' }} + id: baseline-release + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + mkdir -p "$BASELINE_RELEASE_DIR" + asset="abicheck-baseline-${ABICHECK_PROFILE}.tar.zst" + tag=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName \ + --jq '.[0].tagName // empty') + if [ -z "$tag" ]; then + echo "no release to resolve a release-contract baseline from" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset" --dir "$BASELINE_RELEASE_DIR" + + - name: Stage the accepted-main baseline + if: ${{ steps.candidate.outcome == 'success' && github.event_name == 'pull_request' }} + id: baseline-main + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + # Resolve by explicit revision. Not "the newest master run", not a + # cache restore-keys prefix match: the run whose head_sha is exactly + # the commit this pull request is based on. + run_id=$(gh api -X GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/ci-scripts-build.yml/runs" \ + -f head_sha="$BASE_SHA" -f status=completed -f event=push \ + --jq '[.workflow_runs[] | select(.head_sha == env.BASE_SHA)] + | sort_by(.run_number) | last | .id // empty') + if [ -z "$run_id" ]; then + echo "no completed default-branch run found for base $BASE_SHA" + exit 1 + fi + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + mkdir -p "$BASELINE_MAIN_DIR" + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + --name "abicheck-candidate-${ABICHECK_PROFILE}" --dir "$BASELINE_MAIN_DIR" + + # ── Comparison ────────────────────────────────────────────────────── + # One check-target invocation per (component, channel). Every one of + # them reads the same two candidate snapshots captured once upstream: + # nothing here re-extracts, and nothing re-compares for rendering. + + - name: Locate candidate snapshots + id: snapshots + if: ${{ steps.candidate.outcome == 'success' }} + run: ./.ci-local/abicheck-snapshots.sh "$CANDIDATE_DIR" + + - name: 'libpvxs vs accepted-main' + id: main-core + if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + name: libpvxs + profile: ${{ env.ABICHECK_PROFILE }} + baseline-channel: accepted-main + baseline-path: ${{ env.BASELINE_MAIN_DIR }} + baseline-required: 'false' + expected-project-ref: ${{ github.event.pull_request.base.sha }} + expected-baseline-generation: '1' + requested-depth: headers + gate-mode: advisory + new-library: ${{ steps.snapshots.outputs.libpvxs }} + build-config: .ci-local/abicheck.yml + head-sha: ${{ github.sha }} + base-ref: ${{ github.event.pull_request.base.ref }} + severity-preset: default + + - name: 'libpvxsIoc vs accepted-main' + id: main-ioc + if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + name: libpvxsIoc + profile: ${{ env.ABICHECK_PROFILE }} + baseline-channel: accepted-main + baseline-path: ${{ env.BASELINE_MAIN_DIR }} + baseline-required: 'false' + expected-project-ref: ${{ github.event.pull_request.base.sha }} + expected-baseline-generation: '1' + requested-depth: headers + gate-mode: advisory + new-library: ${{ steps.snapshots.outputs.libpvxsioc }} + build-config: .ci-local/abicheck.yml + head-sha: ${{ github.sha }} + base-ref: ${{ github.event.pull_request.base.ref }} + severity-preset: default + + - name: 'libpvxs vs release-contract' + id: rel-core + if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + name: libpvxs + profile: ${{ env.ABICHECK_PROFILE }} + baseline-channel: release-contract + baseline-path: ${{ env.BASELINE_RELEASE_DIR }}/abicheck-baseline-${{ env.ABICHECK_PROFILE }}.tar.zst + baseline-required: 'false' + expected-baseline-generation: '1' + requested-depth: headers + gate-mode: advisory + explicit-id: release + new-library: ${{ steps.snapshots.outputs.libpvxs }} + build-config: .ci-local/abicheck.yml + head-sha: ${{ github.sha }} + severity-preset: default + + - name: 'libpvxsIoc vs release-contract' + id: rel-ioc + if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + name: libpvxsIoc + profile: ${{ env.ABICHECK_PROFILE }} + baseline-channel: release-contract + baseline-path: ${{ env.BASELINE_RELEASE_DIR }}/abicheck-baseline-${{ env.ABICHECK_PROFILE }}.tar.zst + baseline-required: 'false' + expected-baseline-generation: '1' + requested-depth: headers + gate-mode: advisory + explicit-id: release + new-library: ${{ steps.snapshots.outputs.libpvxsioc }} + build-config: .ci-local/abicheck.yml + head-sha: ${{ github.sha }} + severity-preset: default + + # Every check is named explicitly, whether or not it produced a report. + # A check that did not run is declared expected-but-unavailable, so the + # aggregate says "incomplete", never "no changes found". + - name: Collect check reports + if: ${{ always() && steps.candidate.outcome == 'success' }} + run: | + ./.ci-local/abicheck-collect.sh "$REPORT_DIR" \ + "${{ steps.main-core.outputs.check-id || format('libpvxs@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-core.outputs.report-path }}" \ + "${{ steps.main-ioc.outputs.check-id || format('libpvxsIoc@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-ioc.outputs.report-path }}" \ + "${{ steps.rel-core.outputs.check-id || format('libpvxs@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-core.outputs.report-path }}" \ + "${{ steps.rel-ioc.outputs.check-id || format('libpvxsIoc@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-ioc.outputs.report-path }}" + + # One aggregate document over both components and both channels. The + # publisher renders exactly this; it never re-runs a comparison and + # never classifies a verdict of its own. + - name: Aggregate both components + id: aggregate + if: ${{ always() && steps.candidate.outcome == 'success' }} + run: | + set -eu + python -m pip install --disable-pip-version-check -q \ + "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" + # A non-zero exit here is a compatibility/coverage outcome, not an + # operational failure: the gate for this shadow check is advisory. + # What must not happen is a missing or empty aggregate document. + abicheck aggregate "$REPORT_DIR" \ + --manifest "$REPORT_DIR/expected-targets.json" \ + -o "json=$REPORT_DIR/aggregate.json" \ + -o "text=$REPORT_DIR/aggregate.txt" || true + python3 -c "import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d, dict) and 'aggregate_schema_version' in d else 'aggregate document is not a valid aggregate report')" \ + "$REPORT_DIR/aggregate.json" + + - name: Upload ABI reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + retention-days: 30 + name: abicheck-reports-${{ env.ABICHECK_PROFILE }} + path: ${{ env.REPORT_DIR }} + if-no-files-found: error + docker: name: ${{ matrix.name }} runs-on: ubuntu-latest diff --git a/documentation/abicheck.md b/documentation/abicheck.md new file mode 100644 index 000000000..6e720bf0a --- /dev/null +++ b/documentation/abicheck.md @@ -0,0 +1,159 @@ +# Advisory ABI/API check (abicheck) + +PVXS's authoritative ABI check is ABICC, driven by `abi-diff.sh` from +`.github/workflows/release.yml`. Nothing here changes that. This document +describes the *advisory* abicheck integration that runs alongside it during +shadow adoption. + +## What it does + + normal candidate build (one existing matrix leg) + -> capture libpvxs and libpvxsIoc once, at L2 + -> compare against reusable, explicitly identified baselines + -> retain canonical reports and diagnostics + -> trusted, report-only publisher + -> one pull-request comment covering both components + +There is exactly **one build** and **one capture per component per +revision**. The capture is reused for every comparison, for the job summary +and for the pull-request comment. Rendering never re-extracts and never +re-compares. + +## Build reuse + +The capture runs inside the existing `Native Linux (WError)` leg of +`.github/workflows/ci-scripts-build.yml` — the one configuration carrying the +`abicheck: 1` marker. It reads what `cue.py build` installed: + +* `include/pvxs/*.h` — the installed public headers, including the generated + `versionNum.h` +* `lib/$EPICS_HOST_ARCH/libpvxs.so.*`, `lib/$EPICS_HOST_ARCH/libpvxsIoc.so.*` + — the real shared objects, not the symlinks and not the static archives +* the EPICS Base include roots `cue.py prepare` had already set up, read from + `configure/RELEASE.local` + +It does not reconstruct EPICS Makefile internals, copy headers out of `src/` +or `ioc/`, rewrite EPICS configuration, or move `HOME`. It runs after the +leg's own tests so nothing else that needs the checkout is disturbed, and it +runs even if those tests failed, as long as the build produced an +installation — an unrelated red test must not hide an ABI finding. + +`.ci-local/abicheck-inputs.sh` resolves and validates those inputs and emits +the capture Action's `libraries` declaration. It contains no build +orchestration, no report schema and no gate logic. + +## Ownership + +| Component | Owned public surface | Include-only context | +|---|---|---| +| `libpvxs` | Every installed `include/pvxs/*.h` except `iochooks.h` | `include/`, EPICS Base `include/`, `include/os/Linux`, `include/compiler/gcc` | +| `libpvxsIoc` | `include/pvxs/iochooks.h` only | the same roots, plus the installed core headers | + +An include search root is context for the parser. It is not a declaration +that the component must export everything reachable through it. See "Known +issues" — abicheck does not honour that distinction correctly yet. + +## Evidence depth + +`--depth headers` (L2): exported symbols and DWARF (L0/L1) plus the public +header AST. + +This is a **deliberate reduction** from the previous integration, which ran +Bear over a rebuild of both revisions to reach L3. Build-flag and toolchain +drift are **no longer covered**. L4 and L5 source evidence are not collected +and must not be enabled implicitly. Debug information is whatever the normal +build produced; no special build is performed to obtain it, and coverage is +reported as it actually is. + +## Extraction context + +`.ci-local/abicheck.yml` pins `-std=c++17` for header parsing only. PVXS's +supported consumer mode and its own build are C++11; parsing the public +headers in C++11 or C++14 against the runner's libstdc++ 13 is not possible +with the supported CastXML (0.7.0, Clang 20). This is a disclosed deviation +of the extraction context, applied identically to both components and to both +sides of every comparison. `PVXS_API_BUILDING` and `PVXS_ENABLE_EXPERT_API` +are deliberately not defined: the headers are parsed as an ordinary consumer +sees them. + +## Baselines + +Two separate questions, never merged into one number: + +* **accepted-main** — what did this pull request introduce? Resolved by + downloading the `ci-scripts-build.yml` run for the pull request's *exact + base commit*, and rejected by `check-target` if the baseline's recorded + `project_ref` is not that commit. No cache restore-key prefix matching. +* **release-contract** — what changed since the selected supported release? + A `abicheck-baseline-.tar.zst` release asset, published by + `.github/workflows/abicheck-baseline.yml` from a tag build only. + +Both are resolved by explicit revision, component and profile. The profile +id — `linux-x86_64-gcc-default-base7.0-bundled-libevent` — names the build +arrangement, including PVXS's bundled libevent rather than the system one, so +a baseline from a different arrangement resolves as `wrong_profile` rather +than being silently compared. + +A missing, expired, wrong-profile or incompatible baseline produces an +explicit unavailable/incomplete outcome. It never produces a clean result. + +For a historical release with no usable evidence, build that tag once with +`workflow_dispatch` and publish the capture with +`abicheck-baseline.yml`'s own `workflow_dispatch` inputs. The result is +retained; nothing is rebuilt per pull request. + +## Publication + +`.github/workflows/abicheck-report.yml` is a separate, trusted publisher: + +* The analysis workflow has `contents: read` and no secrets. It is never + given write access to make a comment work, and contributor code is never + run under `pull_request_target`. +* The publisher runs from the default branch on `workflow_run`, with only + `actions: read` and `pull-requests: write`. +* It verifies the producer run's repository, workflow path, event, run id and + attempt; resolves the pull request through the API rather than trusting + anything in the artifact; distinguishes the pull request head SHA from the + merge commit that was actually built and verifies their association; + downloads only from that exact run; and treats every byte of the artifact + as untrusted data. +* It never checks out, installs, imports or executes pull-request code or + pull-request-built binaries, and it runs no analysis. +* A publication failure fails visibly and is never reported as a clean + compatibility result. A failed or missing analysis is published as an + explicit incomplete state. + +A trusted reporter does not make contributor-produced report contents trusted +evidence. Origin and assurance are preserved; what is enforced is who the +result is delivered to and what executes while delivering it. + +**Deployment prerequisite:** `workflow_run` only ever runs the copy of the +publisher on the default branch. Until a maintainer merges it there, no pull +request — including the one introducing it — will publish a comment. + +## Gate policy + +Advisory (`gate-mode: advisory`) during shadow adoption. Gate status and +compatibility are reported separately: the check can be green while +prominently reporting a detected break. ABICC remains authoritative. + +## Known issues (abicheck product bugs, measured 2026-09-16) + +Reproduced on this branch with abicheck +`3737f9f960ae14a7b24dbb6e9b686e49fb563673` and CastXML 0.7.0, by comparing a +snapshot against itself (a byte-identical pair, verdict `NO_CHANGE`): + +1. **Include-context headers become export obligations.** 123 EPICS Base + symbols (`epicsMutex::lock()`, `epicsEvent::wait()`, `errVerbose`, …) are + charged to `libpvxs` as its own missing exports, and `pvxs::version_str()` + and friends are charged to `libpvxsIoc`. Both are reached only through + `-I` include roots. +2. **Persistent hygiene findings are reported as changes.** An unchanged + library reports 505 (`libpvxs`) and 339 (`libpvxsIoc`) findings, almost + all `exported_not_public` template guard variables that are identical on + both sides. + +Together these would make an unchanged pull request produce an 844-finding +comment, 126 of them false. Both are fixes owed by abicheck, not by +suppressions here. This integration must not be enabled for anything beyond +shadow reporting until they are fixed. From f1cc10af2a7fcdabbf61b040470c8380635877ff Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 04:20:17 +0000 Subject: [PATCH 22/44] ci: read the snapshot path from the manifest's own snapshot field The first real run of the comparison job failed at "Locate candidate snapshots" with "manifest entry for libpvxs escapes the baseline-set". A baseline-set manifest records two different paths per library, and this script read the wrong one. "artifact" echoes the input binary the producing job dumped -- an absolute path in that job's workspace, which is meaningless once the set has travelled as an artifact. "snapshot" is the snapshot file's own name inside the set. Joining an absolute "artifact" against the download directory yielded a path outside it, which the containment guard then correctly rejected. Read "snapshot" instead, keep the containment guard (it is what caught this), and say "snapshot path ... escapes" so the message names the field. Content identity is deliberately not re-verified here: the manifest's per-artifact sha256 is abicheck's normalised content hash, not a whole-file digest, and reimplementing that recipe would be a second, silently divergent verifier. resolve-baseline, which check-target already invokes, is what validates baseline-set identity. Reproduced and fixed against a real baseline-set built by actions/baseline's own build_manifest.py from this branch's captured snapshots, then moved to a different directory to model the cross-job handoff. Negative controls: path traversal, absolute path, a symlink leaving the set, a missing snapshot file, a component absent from the manifest, an absent manifest, and a malformed manifest are each refused with a distinct message; the valid set resolves both components. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-snapshots.sh | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.ci-local/abicheck-snapshots.sh b/.ci-local/abicheck-snapshots.sh index 25a980ce2..0ec9fd02f 100755 --- a/.ci-local/abicheck-snapshots.sh +++ b/.ci-local/abicheck-snapshots.sh @@ -5,6 +5,12 @@ # manifest.json recording their identities and digests. This reads that # manifest -- it does not guess filenames, and it does not pick whichever # file happens to sort first. +# +# Note the manifest's two different path fields: "artifact" echoes the input +# binary the producing job dumped (an absolute path in that job's workspace, +# meaningless here), while "snapshot" is the snapshot file's own name inside +# the baseline-set. Only the latter is usable after the set has been moved +# between jobs. set -eu DIR=${1:?usage: abicheck-snapshots.sh } @@ -12,27 +18,37 @@ MANIFEST="$DIR/manifest.json" [ -f "$MANIFEST" ] || { echo "no manifest.json in $DIR" >&2; exit 64; } DIR="$DIR" python3 - "$MANIFEST" <<'PY' -import json, os, sys +import json +import os +import sys manifest = json.load(open(sys.argv[1], encoding="utf-8")) root = os.path.realpath(os.environ["DIR"]) wanted = {"libpvxs": "libpvxs", "libpvxsIoc": "libpvxsioc"} -artifacts = manifest.get("artifacts") or manifest.get("libraries") or [] found = {} -for entry in artifacts: - name = entry.get("library") or entry.get("name") +for entry in manifest.get("artifacts", []): + name = entry.get("library") if name not in wanted: continue - rel = entry.get("artifact") or entry.get("snapshot") or entry.get("path") + rel = entry.get("snapshot") if not rel: raise SystemExit(f"manifest entry for {name} names no snapshot file") path = os.path.realpath(os.path.join(root, rel)) - # The manifest is produced in the same job, but treat it as data anyway. + # The manifest is produced by the same run, but it travels as an + # artifact, so treat it as data: a snapshot must be a regular file + # inside the baseline-set, not a link out of it. if os.path.commonpath([root, path]) != root: - raise SystemExit(f"manifest entry for {name} escapes the baseline-set") + raise SystemExit(f"snapshot path for {name} escapes the baseline-set: {rel}") if not os.path.isfile(path): raise SystemExit(f"snapshot for {name} is missing: {path}") + + # Content identity is deliberately NOT re-checked here. The manifest's + # per-artifact sha256 is abicheck's own normalised content hash, not a + # whole-file digest, and reimplementing that recipe here would be a + # second, silently-divergent verifier. resolve-baseline (invoked by + # check-target for the baseline side) is the component that validates + # baseline-set identity. found[name] = path missing = sorted(set(wanted) - set(found)) From 8ffffa8b4c65fa585d517f68ce4c9e99546b919a Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 04:47:33 +0000 Subject: [PATCH 23/44] ci: fix baseline eligibility, report identity and the incomplete contract Addresses the review of f1cc10a. Every finding below was reproduced locally before it was changed. Report matching was silently losing every finding abicheck aggregate matches a report to its expected target by the report's own target_id, falling back to the file stem after the "abi-report-" prefix. abicheck-collect.sh sanitised '@', '#' and '~' out of the check id when naming the file, so the stem could never match and every report aggregated as an unavailable target. Check ids are now written verbatim, with path separators refused. A local run over a real compare report went from 0/2 to 1/2 targets analyzed. Accepted-main baselines were not required to be trustworthy The lookup selected any completed push run at the base SHA, from any branch, regardless of conclusion -- and the capture step deliberately runs after a test failure, so a failed run's snapshot could become the baseline. It now requires this repository's own ci-scripts-build.yml, a push to the pull request's base branch, and conclusion success. "Not found", "lookup failed" and "artifact expired" are distinct outcomes. The publisher conflated the PR head with the tested commit workflow_run.head_sha is the pull request head, not the merge commit the producer built, and it was being emitted as build-sha. The hand-rolled resolution is replaced by abicheck's actions/verify-source-run, which returns pr-head-sha and tested-sha as separate verified coordinates and applies the artifact size/entry/ratio caps. Publication is bound to the triggering run attempt, and concurrency is keyed per pull request and profile rather than per producer run. Producer and publisher disagreed on failure A missing candidate wrote abicheck-incomplete.json and skipped aggregation, while the publisher read aggregate.json. There is now one shape: the expected checks are always declared and aggregated, so an incomplete analysis is an aggregate whose targets are unavailable. Only the checks an event actually has are declared -- accepted-main exists on a pull request, not on a push or tag build. Aggregate validation was a key-presence check {"aggregate_schema_version": "nonsense"} passed it. Replaced with a structural check of status, coverage (against abicheck's own CoverageStatus values), gate and per-target states, and the aggregate exit code is recorded rather than discarded. Release publication could not work as documented The automatic trigger required a "v" prefix; PVXS tags are bare versions such as 1.5.2, so it excluded every real release. Tag identity is now proved through refs/tags/ with annotated-tag resolution, and must point at the built revision. The documented bootstrap was circular -- the workflow at a historical tag has no capture step -- so it now builds the requested revision once in the trusted workflow and captures it with the same shared action the matrix leg uses. --clobber is gone: an existing asset is left in place unless replacement is explicitly requested. Profile and component coverage are checked before upload, since stage-baseline does not check them. Duplicate manifest components were last-one-wins; now refused. Shared .github/actions/abicheck-capture removes the duplication between the matrix capture and the bootstrap build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-collect.sh | 15 +- .ci-local/abicheck-snapshots.sh | 5 + .ci-local/abicheck-validate-aggregate.sh | 65 +++++ .github/actions/abicheck-capture/action.yml | 74 ++++++ .../abicheck-publish-baseline/action.yml | 95 +++++++ .github/workflows/abicheck-baseline.yml | 185 ++++++++++---- .github/workflows/abicheck-report.yml | 231 +++++++----------- .github/workflows/ci-scripts-build.yml | 149 +++++++---- documentation/abicheck.md | 76 +++++- 9 files changed, 636 insertions(+), 259 deletions(-) create mode 100755 .ci-local/abicheck-validate-aggregate.sh create mode 100644 .github/actions/abicheck-capture/action.yml create mode 100644 .github/actions/abicheck-publish-baseline/action.yml diff --git a/.ci-local/abicheck-collect.sh b/.ci-local/abicheck-collect.sh index f51a5a079..86ff85b1b 100755 --- a/.ci-local/abicheck-collect.sh +++ b/.ci-local/abicheck-collect.sh @@ -20,10 +20,19 @@ for spec in "$@"; do id=${spec%%=*} path=${spec#*=} [ -n "$id" ] || { echo "empty check id in '$spec'" >&2; exit 64; } + case "$id" in + */*|*..*) + echo "check id '$id' contains a path separator" >&2; exit 64 ;; + esac if [ -n "$path" ] && [ -f "$path" ]; then - # abicheck aggregate discovers reports by filename prefix. - safe=$(printf '%s' "$id" | tr -c 'A-Za-z0-9._-' '_') - cp "$path" "$DEST/abi-report-$safe.json" + # abicheck aggregate matches a report to its expected target by the + # report's own "target_id" when it has one, and otherwise by the + # file stem after the "abi-report-" prefix. The check id is + # therefore written VERBATIM: sanitising its '@', '#' and '~' + # separators away would make the stem stop matching the expected + # id, and every report would silently aggregate as an unavailable + # target. + cp "$path" "$DEST/abi-report-$id.json" printf '%s\tpresent\n' "$id" >> "$DEST/.collect-index" else printf '%s\tmissing\n' "$id" >> "$DEST/.collect-index" diff --git a/.ci-local/abicheck-snapshots.sh b/.ci-local/abicheck-snapshots.sh index 0ec9fd02f..dfa57958c 100755 --- a/.ci-local/abicheck-snapshots.sh +++ b/.ci-local/abicheck-snapshots.sh @@ -49,6 +49,11 @@ for entry in manifest.get("artifacts", []): # second, silently-divergent verifier. resolve-baseline (invoked by # check-target for the baseline side) is the component that validates # baseline-set identity. + if name in found: + # Two entries for one component is an ambiguous set, not a + # last-one-wins choice: silently overwriting would pick a snapshot + # nobody selected. + raise SystemExit(f"baseline-set declares {name} more than once") found[name] = path missing = sorted(set(wanted) - set(found)) diff --git a/.ci-local/abicheck-validate-aggregate.sh b/.ci-local/abicheck-validate-aggregate.sh new file mode 100755 index 000000000..94c1477e0 --- /dev/null +++ b/.ci-local/abicheck-validate-aggregate.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Validate that a file really is an abicheck aggregate outcome document +# before anything downstream treats it as one. +# +# Checking that a single key is present is not validation: a document that +# merely carries an "aggregate_schema_version" string would pass while +# describing nothing. Operational report loss must not be able to reach the +# publisher disguised as an empty finding set. +set -eu + +DOC=${1:?usage: abicheck-validate-aggregate.sh } +[ -s "$DOC" ] || { echo "aggregate document $DOC is missing or empty" >&2; exit 1; } + +python3 - "$DOC" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + doc = json.load(open(path, encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"aggregate document is not readable JSON: {exc}") + +if not isinstance(doc, dict): + raise SystemExit("aggregate document is not a JSON object") + +version = doc.get("aggregate_schema_version") +if not isinstance(version, str) or not version.strip(): + raise SystemExit("aggregate document declares no schema version") + +if doc.get("status") not in ("pass", "fail"): + raise SystemExit(f"aggregate status is not pass/fail: {doc.get('status')!r}") + +for block in ("compatibility", "coverage", "gate"): + if not isinstance(doc.get(block), dict): + raise SystemExit(f"aggregate document has no {block} block") + +coverage = doc["coverage"] +# abicheck.workflows.aggregate.contracts.CoverageStatus +if coverage.get("status") not in ("complete", "partial", "empty"): + raise SystemExit(f"coverage status is not a CoverageStatus value: {coverage.get('status')!r}") + +targets = doc.get("targets") +if not isinstance(targets, list): + raise SystemExit("aggregate document has no targets list") +if not targets: + # Zero targets means the expected set never reached aggregation. That is + # an operational failure of the producing job, not a clean comparison. + raise SystemExit("aggregate document declares no targets at all") + +for target in targets: + if not isinstance(target, dict) or not target.get("target_id"): + raise SystemExit("aggregate document has a target with no target_id") + if target.get("state") not in ("analyzed", "unavailable"): + raise SystemExit( + f"target {target.get('target_id')!r} has an unrecognised state " + f"{target.get('state')!r}" + ) + +analyzed = sum(1 for t in targets if t.get("state") == "analyzed") +print( + f"aggregate ok: schema {version}, status {doc['status']}, " + f"coverage {coverage['status']}, {analyzed}/{len(targets)} target(s) analyzed" +) +PY diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml new file mode 100644 index 000000000..68c1acac0 --- /dev/null +++ b/.github/actions/abicheck-capture/action.yml @@ -0,0 +1,74 @@ +name: 'PVXS ABI capture' +description: >- + Capture ABI/API evidence for libpvxs and libpvxsIoc from an installation + that a normal PVXS build has already produced. Performs no build, no + comparison and no reporting: it resolves the installed headers and shared + objects, then calls abicheck's own baseline Action once for both + components. + + Used by the selected matrix leg of ci-scripts-build.yml (the candidate + capture) and by abicheck-baseline.yml's one-time historical bootstrap, so + the two produce identical baseline-sets from one definition. + +inputs: + top: + description: > + Root of the built PVXS tree to capture from. Defaults to the current + working directory; the historical bootstrap builds into its own + directory and points this at it. + required: false + default: '' + output-dir: + description: 'Directory to write the baseline-set into.' + required: true + project-ref: + description: > + The revision actually built and analysed, recorded in the manifest. + For a pull_request build this is the merge commit, not the PR head. + required: true + profile: + description: 'Build-profile identifier recorded in the manifest.' + required: true + baseline-generation: + description: 'Scanner-compatibility generation recorded in the manifest.' + required: false + default: '1' + build-config: + description: 'Path to the abicheck extraction config.' + required: false + default: '.ci-local/abicheck.yml' + abicheck-ref: + description: 'Immutable abicheck revision the capture runs from.' + required: true + +outputs: + baseline-path: + description: 'The baseline-set directory that was written.' + value: ${{ steps.capture.outputs.baseline-path }} + content-digest: + description: "Digest over the manifest's artifact list." + value: ${{ steps.capture.outputs.content-digest }} + +runs: + using: 'composite' + steps: + - name: Resolve ABI capture inputs + id: inputs + shell: bash + run: ./.ci-local/abicheck-inputs.sh ${{ inputs.top }} + + - name: Capture ABI snapshots (libpvxs, libpvxsIoc) + id: capture + uses: abicheck/abicheck/actions/baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + libraries: ${{ steps.inputs.outputs.libraries }} + output-dir: ${{ inputs.output-dir }} + project-ref: ${{ inputs.project-ref }} + profile: ${{ inputs.profile }} + depth: headers + build-config: ${{ inputs.build-config }} + baseline-generation: ${{ inputs.baseline-generation }} + generator-action-ref: ${{ inputs.abicheck-ref }} + # A libpvxs snapshot is ~124 MB of JSON at this depth (measured). + # The decoded content is identical either way. + snapshot-compression: zstd diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml new file mode 100644 index 000000000..cf276174a --- /dev/null +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -0,0 +1,95 @@ +name: 'PVXS ABI baseline publication' +description: >- + Package a baseline-set and attach it to a release, after checking that + the set really describes the profile it is being published as. + + Shared by abicheck-baseline.yml's automatic and bootstrap paths so the + eligibility rules exist once. + +inputs: + baseline-path: + description: 'Baseline-set directory (manifest.json plus snapshots).' + required: true + profile: + description: 'Profile this set is being published as.' + required: true + tag: + description: 'Release tag to attach the asset to.' + required: true + overwrite: + description: > + Replace an existing asset of the same name. A published baseline is + an immutable reference: replacing it silently changes the meaning of + every comparison already made against it. + required: false + default: 'false' + github-token: + description: 'Token with contents: write for the release upload.' + required: true + +runs: + using: 'composite' + steps: + # actions/stage-baseline packages whatever it is given and explicitly + # does not check the manifest's own profile against the name it is + # published under, so a mismatch would produce an asset that + # resolve-baseline later rejects as wrong_profile for every consumer. + # Check it here, before anything is uploaded. + - name: Check the set describes the profile it is published as + shell: bash + env: + BASELINE_PATH: ${{ inputs.baseline-path }} + EXPECTED_PROFILE: ${{ inputs.profile }} + run: | + set -euo pipefail + manifest="$BASELINE_PATH/manifest.json" + test -s "$manifest" || { echo "::error::no manifest.json in $BASELINE_PATH"; exit 1; } + actual=$(jq -r '.profile // empty' "$manifest") + if [ "$actual" != "$EXPECTED_PROFILE" ]; then + echo "::error::baseline-set records profile '$actual' but is being published as '$EXPECTED_PROFILE'" + exit 1 + fi + libs=$(jq -r '[.artifacts[].library] | sort | join(",")' "$manifest") + if [ "$libs" != "libpvxs,libpvxsIoc" ]; then + echo "::error::baseline-set covers '$libs', expected both libpvxs and libpvxsIoc" + exit 1 + fi + + - name: Package the baseline-set + id: stage + uses: abicheck/abicheck/actions/stage-baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + with: + baseline-path: ${{ inputs.baseline-path }} + asset-name-template: 'abicheck-baseline-{profile}.tar.zst' + profile: ${{ inputs.profile }} + + - name: Attach to the release + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + ASSET: ${{ steps.stage.outputs.archive-path }} + OVERWRITE: ${{ inputs.overwrite }} + run: | + set -euo pipefail + test -s "$ASSET" + name=$(basename "$ASSET") + + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::no release exists for tag $TAG; create the release before publishing its baseline" + exit 1 + fi + + existing=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq "[.assets[].name] | index(\"$name\") // empty") + if [ -n "$existing" ]; then + if [ "$OVERWRITE" != "true" ]; then + # Re-running publication for a release that already has its + # baseline is a no-op, not a silent replacement. + echo "::notice::$name is already published for $TAG; leaving the existing immutable asset in place" + exit 0 + fi + echo "::warning::replacing the existing $name for $TAG at explicit request" + fi + + gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 18789ff85..7f9617cf0 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -1,19 +1,26 @@ # Publish the release-contract ABI baseline-set for the selected profile. # -# The snapshots themselves are not produced here: they are the same capture -# ci-scripts-build.yml already performed for the tagged build. This workflow -# packages that capture and attaches it to the release, so later pull -# requests have something to compare against without rebuilding history. +# Two paths, both running only from this repository's default branch: # -# Trust boundary: it publishes ONLY from a tag build of this repository -- -# never from a pull request, and never from a fork. A snapshot produced by -# contributor code can never reach the release-contract channel. +# automatic A tag build of PVXS EPICS already captured the tagged +# revision. This packages that capture and attaches it to the +# release. Nothing is rebuilt. # -# The accepted-main channel needs nothing here. A pull request resolves it -# by downloading the ci-scripts-build.yml run for its own base commit, keyed -# by that exact SHA (see the `Stage the accepted-main baseline` step there), -# and check-target rejects a baseline whose recorded project_ref is not that -# commit. +# bootstrap A historical release predates the capture integration, so +# no tag build of it ever produced a baseline-set. Dispatching +# the old workflow cannot help: the workflow AT that tag has +# no capture step. This path therefore builds the requested +# revision once, here, with the current trusted tooling, and +# publishes the result. It is a one-time operation per +# release, never part of a pull request. +# +# Trust boundary: publication happens only for a verified tag of this +# repository, from a successful producer run (automatic) or from a build +# this trusted workflow performed itself (bootstrap). A snapshot produced +# by contributor code can never reach the release-contract channel. +# +# The accepted-main channel needs nothing here: a pull request resolves it +# from the successful default-branch run for its own base commit. name: ABI baseline @@ -21,21 +28,20 @@ on: workflow_run: workflows: ["PVXS EPICS"] types: [completed] - # One-time bootstrap for a historical release that predates this check: - # build that tag once by hand (workflow_dispatch on PVXS EPICS at the tag), - # then publish the resulting capture here. The result is retained as a - # release asset; it is never rebuilt per pull request, and a missing one is - # reported as a missing baseline rather than silently replaced. workflow_dispatch: inputs: - run-id: - description: 'PVXS EPICS run id whose captured snapshots to publish.' - required: true - type: string tag: - description: 'Release tag to attach the baseline-set to.' + description: 'Release tag to build, capture and publish a baseline-set for.' required: true type: string + overwrite: + description: > + Replace an existing asset of the same name. Off by default: a + published baseline is an immutable reference, and silently + replacing it changes the meaning of every past comparison. + required: false + default: false + type: boolean permissions: contents: read @@ -43,66 +49,145 @@ permissions: env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + SETUP_PATH: .ci-local + CMP: gcc + BCFG: default + BASE: "7.0" + SET: defaults jobs: + # ── Automatic: package the tag build's own capture ──────────────────── publish: if: >- - github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.event == 'push' && - github.event.workflow_run.conclusion == 'success' && - startsWith(github.event.workflow_run.head_branch, 'v')) + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest timeout-minutes: 15 permissions: actions: read - # The single write scope in this integration, and only here: attaching - # the packaged baseline-set to an existing release of this repository. contents: write steps: - - name: Resolve the producer run + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Verify the producer run and that it built a real tag id: source env: GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ inputs.run-id || github.event.workflow_run.id }} - TAG: ${{ inputs.tag }} + RUN_ID: ${{ github.event.workflow_run.id }} run: | set -euo pipefail run_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") test "$(jq -r '.repository.full_name' <<<"$run_json")" = "$GITHUB_REPOSITORY" test "$(jq -r '.path' <<<"$run_json")" = ".github/workflows/ci-scripts-build.yml" - event=$(jq -r '.event' <<<"$run_json") - case "$event" in - push|workflow_dispatch) ;; - *) echo "::error::run $RUN_ID was triggered by '$event'; only a tag build may publish a baseline"; exit 1 ;; - esac + test "$(jq -r '.event' <<<"$run_json")" = "push" + test "$(jq -r '.conclusion' <<<"$run_json")" = "success" head_sha=$(jq -r '.head_sha' <<<"$run_json") - tag=${TAG:-$(jq -r '.head_branch' <<<"$run_json")} - # The tag must exist and must point at the commit that was built. - tag_sha=$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq '.sha') - test "$tag_sha" = "$head_sha" - { echo "run-id=$RUN_ID"; echo "tag=$tag"; echo "sha=$head_sha"; } >> "$GITHUB_OUTPUT" + tag=$(jq -r '.head_branch' <<<"$run_json") + + # PVXS tags are bare versions ("1.5.2"), not "v"-prefixed, so a + # name-shaped guard would exclude every real release. Instead prove + # the name IS a tag by resolving refs/tags/ explicitly -- the + # commits endpoint accepts any revision name and would happily + # resolve a branch. + if ! ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>/dev/null); then + echo "run $RUN_ID built '$tag', which is not a tag of this repository; nothing to publish" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + obj_sha=$(jq -r '.object.sha' <<<"$ref") + if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then + # Annotated tag: peel it to the commit it points at. + obj_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$obj_sha" --jq '.object.sha') + fi + test "$obj_sha" = "$head_sha" + { echo "run-id=$RUN_ID"; echo "tag=$tag"; echo "skip=false"; } >> "$GITHUB_OUTPUT" - uses: actions/download-artifact@v7 + if: ${{ steps.source.outputs.skip == 'false' }} with: name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} path: ${{ runner.temp }}/baseline-set run-id: ${{ steps.source.outputs.run-id }} github-token: ${{ github.token }} - - name: Package the baseline-set - id: stage - uses: abicheck/abicheck/actions/stage-baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + - name: Publish the baseline-set + if: ${{ steps.source.outputs.skip == 'false' }} + uses: ./.github/actions/abicheck-publish-baseline with: baseline-path: ${{ runner.temp }}/baseline-set - asset-name-template: 'abicheck-baseline-{profile}.tar.zst' profile: ${{ env.ABICHECK_PROFILE }} + tag: ${{ steps.source.outputs.tag }} + overwrite: 'false' + github-token: ${{ github.token }} + + # ── Bootstrap: build a historical release once, here ────────────────── + bootstrap: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + steps: + # The trusted tooling (capture action, helper scripts) comes from the + # default branch; the revision being captured is checked out + # separately. The historical tree's own workflow is never run. + - uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Attach to the release + - name: Verify the requested tag + id: tag env: GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.source.outputs.tag }} - ASSET: ${{ steps.stage.outputs.archive-path }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail - test -s "$ASSET" - gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" --clobber + ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") + obj_sha=$(jq -r '.object.sha' <<<"$ref") + if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then + obj_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$obj_sha" --jq '.object.sha') + fi + echo "sha=$obj_sha" >> "$GITHUB_OUTPUT" + + - name: Check out the historical revision + uses: actions/checkout@v6 + with: + ref: ${{ steps.tag.outputs.sha }} + submodules: true + persist-credentials: false + path: historical + + - name: apt-get install + run: | + sudo apt-get update + sudo apt-get -y install libreadline-dev cmake + + - name: Build the historical revision + working-directory: historical + run: | + set -eu + python .ci/cue.py prepare + python .ci/cue.py exec python .ci-local/libevent.py + python .ci/cue.py build + + - name: Capture the historical revision + id: capture + uses: ./.github/actions/abicheck-capture + with: + top: ${{ github.workspace }}/historical + output-dir: ${{ runner.temp }}/baseline-set + project-ref: ${{ steps.tag.outputs.sha }} + profile: ${{ env.ABICHECK_PROFILE }} + abicheck-ref: ${{ env.ABICHECK_REF }} + + - name: Publish the baseline-set + uses: ./.github/actions/abicheck-publish-baseline + with: + baseline-path: ${{ runner.temp }}/baseline-set + profile: ${{ env.ABICHECK_PROFILE }} + tag: ${{ inputs.tag }} + overwrite: ${{ inputs.overwrite }} + github-token: ${{ github.token }} diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index bfab37bbe..3b55d466a 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -1,26 +1,31 @@ # Trusted publisher for the advisory ABI/API check. # -# The analysis that produces the reports runs unprivileged, in -# ci-scripts-build.yml, under the contributor's own pull request -- including -# pull requests from forks. That job has `contents: read` and no secrets, so -# it cannot comment, and it must not be given the ability to. +# The analysis runs unprivileged in ci-scripts-build.yml, under the +# contributor's own pull request, including from forks. That job has +# `contents: read` and no secrets, so it cannot comment, and must not be +# given the ability to. This workflow is the other half. # -# This workflow is the other half. It runs from the repository's default -# branch, on workflow_run, with only the two permissions a comment actually -# needs. It never checks out, installs, imports or executes anything the -# pull request produced: it downloads that run's report artifact, treats -# every byte of it as untrusted data, and renders it with reviewed, -# pinned publisher code. +# Almost all of the work is done by two reviewed abicheck Actions rather +# than by logic grown here: # -# It does not make the contributor's reports trusted evidence. The reports -# say where they came from and how complete they are, and that is preserved. -# What this workflow enforces is who the result may be delivered to and what -# is allowed to execute while delivering it. +# actions/verify-source-run establishes WHICH run this is, WHICH pull +# request it belongs to, and WHAT commit was +# actually analysed -- all from the GitHub +# API, never from the artifact -- and extracts +# the artifact under size/entry/ratio caps. +# actions/report renders the producer's canonical aggregate +# document and maintains the sticky comment. +# It runs no analysis, no compiler and no +# build query. # -# DEPLOYMENT PREREQUISITE: workflow_run only ever runs the copy of this file -# on the default branch. Until a maintainer has merged it there, nothing in -# a pull request -- including the pull request that adds this file -- will -# publish a comment. That is by design and must not be worked around. +# Neither makes the contributor's report contents trusted evidence. Origin +# and assurance are preserved; what is enforced is the recipient and +# execution boundary. +# +# DEPLOYMENT PREREQUISITE: workflow_run only ever runs the copy of this +# file on the default branch. Until a maintainer has merged it there, +# nothing in a pull request -- including the pull request that adds this +# file -- publishes a comment. That is by design. name: ABI/API report @@ -29,152 +34,98 @@ on: workflows: ["PVXS EPICS"] types: [completed] -# Only what publishing a comment needs. No contents: write, no packages, -# no id-token, no secrets. permissions: actions: read pull-requests: write concurrency: - # One in-flight publication per producer run. Superseded runs are NOT - # cancelled: ordering is enforced by the publisher's own freshness check - # against what is already on the pull request, because cancellation alone - # cannot stop an older run that has already started from finishing last. - group: abicheck-report-${{ github.event.workflow_run.id }} + # One publication at a time per pull request and profile, so two producer + # runs for the same PR cannot interleave their comment updates. Keyed on + # the producer's head branch because the PR number is only known after + # verification. Superseded runs are NOT cancelled: ordering is enforced + # by the publisher's own freshness check against what is already on the + # pull request, which cancellation alone cannot do. + group: >- + abicheck-report-${{ github.event.workflow_run.head_repository.full_name }}-${{ + github.event.workflow_run.head_branch }}-linux-x86_64-gcc-default-base7.0-bundled-libevent cancel-in-progress: false jobs: publish: - # Only a pull-request-triggered producer run has a pull request to - # report to. A push/tag run publishes baselines instead (see - # abicheck-baseline.yml) and is ignored here. + # Only a pull-request-triggered producer has a pull request to report + # to. A push/tag run publishes baselines instead (abicheck-baseline.yml). if: ${{ github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest timeout-minutes: 10 env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - REPORTS: ${{ runner.temp }}/abicheck-reports steps: - # ── 1. Establish who this run is, and who it is allowed to talk to ── + # Establishes identity and hands back a verified artifact. Every + # coordinate below comes from the API: the artifact is contributor + # controlled and may claim any repository, pull request or SHA. # - # Everything below comes from the GitHub API, never from the artifact: - # the artifact is contributor-controlled and may claim any repository, - # pull request or SHA it likes. - - name: Resolve and verify the producer run + # `allowed-conclusions` is deliberately empty. A producer run that + # failed for an unrelated reason (a flaky platform test) still has a + # real ABI result to report, and a failed analysis must be published as + # an explicit incomplete state rather than silently withheld. What is + # never inferred is a compatibility outcome from the run's conclusion. + - name: Verify the producer run and fetch its reports id: source - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} - run: | - set -euo pipefail - - run_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") - - # Repository, workflow identity and event must all be what we expect. - test "$(jq -r '.repository.full_name' <<<"$run_json")" = "$GITHUB_REPOSITORY" - test "$(jq -r '.path' <<<"$run_json")" = ".github/workflows/ci-scripts-build.yml" - test "$(jq -r '.event' <<<"$run_json")" = "pull_request" - - attempt=$(jq -r '.run_attempt' <<<"$run_json") - head_sha=$(jq -r '.head_sha' <<<"$run_json") - conclusion=$(jq -r '.conclusion' <<<"$run_json") - - # Resolve the pull request through the API, by the producer run's own - # head SHA. Exactly one open pull request must match; anything else - # is ambiguous and fails visibly rather than guessing a recipient. - prs=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha/pulls" \ - --jq '[.[] | select(.state == "open") | .number]') - count=$(jq 'length' <<<"$prs") - if [ "$count" != "1" ]; then - echo "::error::cannot unambiguously associate run $RUN_ID (head $head_sha) with a pull request: matched $count" - exit 1 - fi - pr=$(jq -r '.[0]' <<<"$prs") - - pr_json=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr") - pr_head_sha=$(jq -r '.head.sha' <<<"$pr_json") - - # The PR head SHA and the SHA that was actually built are different - # things: for a pull_request event the producer builds the merge - # commit. Both are reported; neither is assumed to be the other. - # What is verified is that the merge commit really descends from the - # current PR head. - if [ "$head_sha" != "$pr_head_sha" ]; then - parents=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha" --jq '[.parents[].sha]') - if ! jq -e --arg s "$pr_head_sha" 'index($s)' >/dev/null <<<"$parents"; then - echo "::error::built commit $head_sha is not a merge of the current pull request head $pr_head_sha (the pull request moved); refusing to publish a stale result" - exit 1 - fi - fi - - { - echo "pr=$pr" - echo "run-id=$RUN_ID" - echo "run-attempt=$attempt" - echo "build-sha=$head_sha" - echo "pr-head-sha=$pr_head_sha" - echo "conclusion=$conclusion" - } >> "$GITHUB_OUTPUT" - - # ── 2. Fetch the reports from that exact run ──────────────────────── - - name: Download the report artifact - id: artifact - continue-on-error: true - uses: actions/download-artifact@v7 + uses: abicheck/abicheck/actions/verify-source-run@cb6102d85eaec62874f311646223cfbc1ef3cb25 with: - name: abicheck-reports-${{ env.ABICHECK_PROFILE }} - path: ${{ env.REPORTS }} - run-id: ${{ steps.source.outputs.run-id }} + source-run-id: ${{ github.event.workflow_run.id }} + expect-repository: ${{ github.repository }} + expect-workflow: .github/workflows/ci-scripts-build.yml + expect-event: pull_request + # Bind to the attempt that actually triggered this publication, not + # to whatever attempt the API reports by the time we ask. + expect-run-attempt: ${{ github.event.workflow_run.run_attempt }} + allowed-conclusions: '' + artifact-name: abicheck-reports-${{ env.ABICHECK_PROFILE }} + destination: ${{ runner.temp }}/abicheck-reports github-token: ${{ github.token }} - # ── 3. Publish ───────────────────────────────────────────────────── - # - # The publisher installs nothing but abicheck itself, runs no compiler, - # no build query and no analysis, and renders the aggregate document the - # producer already wrote. Two components, two baseline channels, one - # comment. - # - # NOTE: this step is pending abicheck's `actions/report`, which does not - # exist on abicheck main yet. See documentation/abicheck.md for the - # dependency and the exact revision this workflow was tested against. - # Do not point it at a mutable ref. + - name: Refuse to publish an unverified run + if: ${{ steps.source.outputs.verified != 'true' }} + run: | + echo "::error::refusing to publish: ${{ steps.source.outputs.refusal-code }}" + exit 1 + + # `sha` is the commit that was ACTUALLY analysed. For a pull_request + # producer that is the ephemeral merge commit, not the pull request + # head -- verify-source-run returns them as separate coordinates and + # has already verified their association. Do not substitute one for + # the other. - name: Publish the ABI/API report - if: ${{ steps.artifact.outcome == 'success' }} - uses: abicheck/abicheck/actions/report@PENDING_ABICHECK_REPORT_ACTION_SHA + id: publish + uses: abicheck/abicheck/actions/report@cb6102d85eaec62874f311646223cfbc1ef3cb25 with: - report: ${{ env.REPORTS }}/aggregate.json + report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} - pr-number: ${{ steps.source.outputs.pr }} - sha: ${{ steps.source.outputs.build-sha }} - comment-identity: abicheck/${{ env.ABICHECK_PROFILE }} - run-label: >- - run ${{ steps.source.outputs.run-id }} - (attempt ${{ steps.source.outputs.run-attempt }}) - report-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ steps.source.outputs.run-id }} + pr-number: ${{ steps.source.outputs.pr-number }} + sha: ${{ steps.source.outputs.tested-sha }} + profile: ${{ env.ABICHECK_PROFILE }} + post-on: changes detail: standard - on: changes + run-label: >- + run ${{ github.event.workflow_run.id }} + (attempt ${{ github.event.workflow_run.run_attempt }}) + report-url: ${{ github.event.workflow_run.html_url }} job-summary: 'true' github-token: ${{ github.token }} - # A failed or missing analysis is reported as a failed or missing - # analysis, on the pull request, where the association was established. - # It is never allowed to look like a clean compatibility result, and it - # is never inferred from the producer run's overall conclusion -- an - # unrelated red test leg does not mean the ABI check failed. - - name: Publish an incomplete-analysis notice - if: ${{ steps.artifact.outcome != 'success' }} - uses: abicheck/abicheck/actions/report@PENDING_ABICHECK_REPORT_ACTION_SHA - with: - report: '' - incomplete-reason: >- - No ABI/API report artifact was produced by run - ${{ steps.source.outputs.run-id }}. The comparison did not complete; - this is not a statement that no changes were found. - repository: ${{ github.repository }} - pr-number: ${{ steps.source.outputs.pr }} - sha: ${{ steps.source.outputs.build-sha }} - comment-identity: abicheck/${{ env.ABICHECK_PROFILE }} - run-label: run ${{ steps.source.outputs.run-id }} - report-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ steps.source.outputs.run-id }} - on: always - github-token: ${{ github.token }} + # A publication failure is an operational failure of this workflow and + # is reported as one. It is never allowed to look like a clean + # compatibility result, and a compatibility verdict never fails this + # workflow -- this is a reporting job, not a gate. + - name: Report the publication outcome + if: ${{ always() && steps.source.outputs.verified == 'true' }} + run: | + set -eu + echo "posted=${{ steps.publish.outputs.posted }}" + echo "skipped-reason=${{ steps.publish.outputs.skipped-reason }}" + echo "pr=${{ steps.source.outputs.pr-number }} tested-sha=${{ steps.source.outputs.tested-sha }} pr-head=${{ steps.source.outputs.pr-head-sha }} from-fork=${{ steps.source.outputs.from-fork }}" + if [ "${{ steps.publish.outcome }}" != "success" ]; then + echo "::error::the ABI/API report could not be published (the analysis result itself is unaffected)" + exit 1 + fi diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index e5db892a5..0cd6b1199 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -253,31 +253,17 @@ jobs: # header AST. That is a deliberate reduction from the previous # integration, which rebuilt both revisions under Bear to reach L3: # build-drift coverage is NOT part of this check any more. - - name: Resolve ABI capture inputs - id: abi-inputs - if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' }} - run: ./.ci-local/abicheck-inputs.sh - - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: abi-capture - if: ${{ always() && steps.abi-inputs.outcome == 'success' }} - uses: abicheck/abicheck/actions/baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' }} + uses: ./.github/actions/abicheck-capture with: - libraries: ${{ steps.abi-inputs.outputs.libraries }} output-dir: ${{ runner.temp }}/abicheck-candidate - # The revision actually built and analysed here. For a pull_request - # this is the merge commit, NOT the PR head SHA -- the publisher - # reports both, and never conflates them. + # The revision actually built and analysed here. For a + # pull_request this is the merge commit, NOT the PR head. project-ref: ${{ github.sha }} profile: ${{ env.ABICHECK_PROFILE }} - depth: headers - build-config: .ci-local/abicheck.yml - baseline-generation: '1' - generator-action-ref: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 - # A libpvxs snapshot is ~124 MB of JSON at this depth (measured); - # two of them per run is worth compressing before it becomes CI - # artifact storage. The decoded content is identical. - snapshot-compression: zstd + abicheck-ref: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 - name: Record analysis context id: abi-context @@ -389,14 +375,10 @@ jobs: name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} path: ${{ env.CANDIDATE_DIR }} - - name: Record a missing candidate capture + - name: Note a missing candidate capture if: ${{ steps.candidate.outcome != 'success' }} run: | - set -eu - mkdir -p "$REPORT_DIR" - printf '%s\n' '{"state":"candidate-capture-unavailable"}' \ - > "$REPORT_DIR/abicheck-incomplete.json" - echo "::warning::No ABI snapshots were captured for profile $ABICHECK_PROFILE; reporting an incomplete analysis." + echo "::warning::No ABI snapshots were captured for profile $ABICHECK_PROFILE; every check will aggregate as unavailable." # ── Baseline staging ──────────────────────────────────────────────── # Two distinct questions, two distinct channels, never merged: @@ -430,24 +412,51 @@ jobs: env: GH_TOKEN: ${{ github.token }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} run: | - set -eu - # Resolve by explicit revision. Not "the newest master run", not a - # cache restore-keys prefix match: the run whose head_sha is exactly - # the commit this pull request is based on. - run_id=$(gh api -X GET \ - "repos/$GITHUB_REPOSITORY/actions/workflows/ci-scripts-build.yml/runs" \ - -f head_sha="$BASE_SHA" -f status=completed -f event=push \ - --jq '[.workflow_runs[] | select(.head_sha == env.BASE_SHA)] - | sort_by(.run_number) | last | .id // empty') + set -euo pipefail + # A baseline is eligible only if ALL of these hold. Selecting by + # exact base SHA alone is not enough: a push run at that SHA can + # come from any branch and can have failed, and the capture step + # deliberately still runs after a test failure -- so without these + # filters a failed run's snapshot could become the baseline this + # pull request is measured against. + # + # * produced by this repository's own ci-scripts-build.yml + # * triggered by a push to the pull request's own base branch + # * head_sha exactly the commit this pull request is based on + # * concluded success + # + # "No eligible run" and "the lookup itself failed" are different + # outcomes and are reported differently. + if ! runs=$(gh api -X GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/ci-scripts-build.yml/runs" \ + -f head_sha="$BASE_SHA" -f status=completed -f event=push \ + -f branch="$BASE_REF" --paginate); then + echo "::warning::baseline lookup failed for base $BASE_SHA (GitHub API error); reporting an unavailable baseline, not a clean result" + echo "state=lookup-failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + run_id=$(jq -r --arg sha "$BASE_SHA" --arg ref "$BASE_REF" ' + [ .workflow_runs[] + | select(.head_sha == $sha) + | select(.head_branch == $ref) + | select(.conclusion == "success") ] + | sort_by(.run_number) | last | .id // empty' <<<"$runs") if [ -z "$run_id" ]; then - echo "no completed default-branch run found for base $BASE_SHA" + echo "no successful $BASE_REF run found for base $BASE_SHA" + echo "state=not-found" >> "$GITHUB_OUTPUT" exit 1 fi echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + echo "state=resolved" >> "$GITHUB_OUTPUT" mkdir -p "$BASELINE_MAIN_DIR" - gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ - --name "abicheck-candidate-${ABICHECK_PROFILE}" --dir "$BASELINE_MAIN_DIR" + if ! gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + --name "abicheck-candidate-${ABICHECK_PROFILE}" --dir "$BASELINE_MAIN_DIR"; then + echo "::warning::baseline run $run_id has no abicheck-candidate-$ABICHECK_PROFILE artifact (expired or never produced)" + echo "state=artifact-unavailable" >> "$GITHUB_OUTPUT" + exit 1 + fi # ── Comparison ────────────────────────────────────────────────────── # One check-target invocation per (component, channel). Every one of @@ -537,37 +546,69 @@ jobs: head-sha: ${{ github.sha }} severity-preset: default - # Every check is named explicitly, whether or not it produced a report. - # A check that did not run is declared expected-but-unavailable, so the - # aggregate says "incomplete", never "no changes found". + # Every check this event was supposed to produce is named explicitly, + # whether or not it produced a report. A check that did not run is + # declared expected-but-unavailable, so the aggregate says "incomplete" + # rather than "no changes found". + # + # The accepted-main checks exist only on a pull request: there is no PR + # base to compare against on a push or tag build, and declaring them + # there would manufacture unavailable checks for a question the event + # never asked. - name: Collect check reports - if: ${{ always() && steps.candidate.outcome == 'success' }} + if: ${{ always() }} run: | - ./.ci-local/abicheck-collect.sh "$REPORT_DIR" \ - "${{ steps.main-core.outputs.check-id || format('libpvxs@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-core.outputs.report-path }}" \ - "${{ steps.main-ioc.outputs.check-id || format('libpvxsIoc@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-ioc.outputs.report-path }}" \ - "${{ steps.rel-core.outputs.check-id || format('libpvxs@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-core.outputs.report-path }}" \ - "${{ steps.rel-ioc.outputs.check-id || format('libpvxsIoc@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-ioc.outputs.report-path }}" + set -eu + checks="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + checks="$checks ${{ steps.main-core.outputs.check-id || format('libpvxs@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-core.outputs.report-path }}" + checks="$checks ${{ steps.main-ioc.outputs.check-id || format('libpvxsIoc@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-ioc.outputs.report-path }}" + fi + checks="$checks ${{ steps.rel-core.outputs.check-id || format('libpvxs@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-core.outputs.report-path }}" + checks="$checks ${{ steps.rel-ioc.outputs.check-id || format('libpvxsIoc@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-ioc.outputs.report-path }}" + # shellcheck disable=SC2086 + ./.ci-local/abicheck-collect.sh "$REPORT_DIR" $checks # One aggregate document over both components and both channels. The # publisher renders exactly this; it never re-runs a comparison and # never classifies a verdict of its own. + # One aggregate document over every check this event declared. The + # publisher renders exactly this; it never re-runs a comparison and + # never classifies a verdict of its own. + # + # This runs even when no candidate was captured: an aggregate over + # entirely unavailable targets is the canonical way to say "the + # analysis did not complete", and it is the same document shape the + # publisher reads on the happy path. There is no second, divergent + # incomplete-state file. - name: Aggregate both components id: aggregate - if: ${{ always() && steps.candidate.outcome == 'success' }} + if: ${{ always() }} run: | - set -eu + set -euo pipefail python -m pip install --disable-pip-version-check -q \ "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" - # A non-zero exit here is a compatibility/coverage outcome, not an - # operational failure: the gate for this shadow check is advisory. - # What must not happen is a missing or empty aggregate document. + + # abicheck aggregate's exit code is a compatibility/coverage + # decision (0 pass, 1 coverage/quality, 2 API break, 4 ABI break), + # and this shadow check's gate is advisory -- so a non-zero code is + # recorded, not swallowed. 64 is a usage error and is ours to fix. + rc=0 abicheck aggregate "$REPORT_DIR" \ --manifest "$REPORT_DIR/expected-targets.json" \ -o "json=$REPORT_DIR/aggregate.json" \ - -o "text=$REPORT_DIR/aggregate.txt" || true - python3 -c "import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d, dict) and 'aggregate_schema_version' in d else 'aggregate document is not a valid aggregate report')" \ - "$REPORT_DIR/aggregate.json" + -o "text=$REPORT_DIR/aggregate.txt" || rc=$? + echo "exit-code=$rc" >> "$GITHUB_OUTPUT" + if [ "$rc" = "64" ]; then + echo "::error::abicheck aggregate rejected its inputs (usage error)" + exit 1 + fi + + # Validate the document we are about to hand downstream, rather + # than checking that one key exists. A file that does not describe + # a real aggregate outcome is an operational failure of this job, + # never an empty finding set. + ./.ci-local/abicheck-validate-aggregate.sh "$REPORT_DIR/aggregate.json" - name: Upload ABI reports if: ${{ always() }} diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 6e720bf0a..ffc84ecee 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -94,13 +94,34 @@ arrangement, including PVXS's bundled libevent rather than the system one, so a baseline from a different arrangement resolves as `wrong_profile` rather than being silently compared. +Selection by base SHA alone is not sufficient, so an accepted-main +baseline must additionally come from this repository's own +`ci-scripts-build.yml`, from a push to the pull request's base branch, and +from a run that **concluded success**. Without the last two, a failed run's +snapshot could become the baseline — the capture step deliberately still +runs after a test failure. "No eligible run", "the lookup failed" and "the +artifact expired" are reported as three different outcomes. + A missing, expired, wrong-profile or incompatible baseline produces an explicit unavailable/incomplete outcome. It never produces a clean result. -For a historical release with no usable evidence, build that tag once with -`workflow_dispatch` and publish the capture with -`abicheck-baseline.yml`'s own `workflow_dispatch` inputs. The result is -retained; nothing is rebuilt per pull request. +### Bootstrapping a historical release + +A release that predates this integration has no capture, and re-dispatching +the old workflow cannot produce one — the workflow *at that tag* has no +capture step. `abicheck-baseline.yml`'s `workflow_dispatch` path therefore +builds the requested revision once, in the trusted default-branch workflow, +captures it with the same shared action the matrix leg uses, and publishes +the result. It is a one-time operation per release and never runs on a pull +request. + +Publication checks that the tag really is a tag (`refs/tags/`, +resolving annotated tags), that it points at the revision that was built, +and that the baseline-set's own manifest records the profile it is being +published as and covers both components. An already-published asset is left +alone rather than replaced, because a published baseline is an immutable +reference; replacing one changes the meaning of every comparison already +made against it. ## Publication @@ -111,17 +132,23 @@ retained; nothing is rebuilt per pull request. run under `pull_request_target`. * The publisher runs from the default branch on `workflow_run`, with only `actions: read` and `pull-requests: write`. -* It verifies the producer run's repository, workflow path, event, run id and - attempt; resolves the pull request through the API rather than trusting - anything in the artifact; distinguishes the pull request head SHA from the - merge commit that was actually built and verifies their association; - downloads only from that exact run; and treats every byte of the artifact - as untrusted data. +* It delegates to two reviewed abicheck Actions rather than growing its own + logic: `actions/verify-source-run` establishes which run this is, which + pull request it belongs to and which commit was actually analysed — all + from the GitHub API, never from the artifact — and extracts the artifact + under size, entry-count and compression-ratio caps; + `actions/report` renders the producer's canonical aggregate document and + maintains the sticky comment. +* The pull request head SHA and the commit that was actually analysed are + separate coordinates. For a `pull_request` producer the analysed commit + is the ephemeral merge commit, not the PR head, and the comment shows the + analysed one. They are never substituted for each other. +* Publication is bound to the producer attempt that triggered it, and + concurrency is keyed per pull request and profile. * It never checks out, installs, imports or executes pull-request code or pull-request-built binaries, and it runs no analysis. * A publication failure fails visibly and is never reported as a clean - compatibility result. A failed or missing analysis is published as an - explicit incomplete state. + compatibility result. A compatibility verdict never fails the publisher. A trusted reporter does not make contributor-produced report contents trusted evidence. Origin and assurance are preserved; what is enforced is who the @@ -131,12 +158,37 @@ result is delivered to and what executes while delivering it. publisher on the default branch. Until a maintainer merges it there, no pull request — including the one introducing it — will publish a comment. +## Incomplete analyses + +There is one document shape, not two. When no candidate capture exists, or +a comparison did not run, the expected checks are still declared and +`abicheck aggregate` produces an aggregate document in which those targets +are `unavailable`. That is the same shape the publisher reads on the happy +path, so a producer failure cannot arrive at the publisher as a file it +does not understand. The document is structurally validated before it is +handed downstream — its status, coverage, gate blocks and target states — +rather than merely checked for the presence of a schema key. + +Only the checks an event actually has are declared: the accepted-main +comparisons exist on a pull request, not on a push or tag build. + ## Gate policy Advisory (`gate-mode: advisory`) during shadow adoption. Gate status and compatibility are reported separately: the check can be green while prominently reporting a detected break. ABICC remains authoritative. +## Dependency status + +The publisher is pinned to abicheck `cb6102d85eaec62874f311646223cfbc1ef3cb25`, +which carries `actions/verify-source-run` and `actions/report`. That +revision is immutable but **not yet reviewed or merged** — it is a branch +tip awaiting its own pull request in abicheck. The PVXS caller's inputs +have been checked against that revision's declared schema, but no deployed +publication has been demonstrated, because `workflow_run` only runs the +default-branch copy of the publisher. This must not go upstream on an +unreviewed pin. + ## Known issues (abicheck product bugs, measured 2026-09-16) Reproduced on this branch with abicheck From 3732bc9af60e1f22713623b73b8c0615e8fb1a29 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 04:49:48 +0000 Subject: [PATCH 24/44] ci: drop the redundant ABI capture failure marker The capture-failure steps referenced steps.abi-inputs, which stopped existing when the resolver moved into the shared capture action -- the reference rendered as an empty string in the marker it wrote. Rather than repair it, remove it. The artifact it produced was never consumed by anything, and it was a second way of saying "the analysis did not complete" alongside the aggregate-of-unavailable-targets shape this branch just made canonical. A one-line warning replaces it; the comparison job still reports the incomplete outcome. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/workflows/ci-scripts-build.yml | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 0cd6b1199..c7646ee80 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -309,26 +309,10 @@ jobs: path: ${{ runner.temp }}/abicheck-candidate if-no-files-found: error - - name: Report a failed ABI capture + - name: Note a failed ABI capture if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' && steps.abi-capture.outcome != 'success' }} run: | - set -eu - mkdir -p "$RUNNER_TEMP/abicheck-candidate-failure" - printf '%s\n' \ - '{"state":"capture-failed",' \ - ' "inputs_outcome":"${{ steps.abi-inputs.outcome }}",' \ - ' "capture_outcome":"${{ steps.abi-capture.outcome }}"}' \ - > "$RUNNER_TEMP/abicheck-candidate-failure/state.json" - echo "::warning::ABI capture did not complete; downstream comparison will report an incomplete analysis, not a clean result." - - - name: Upload ABI capture failure marker - if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' && steps.abi-capture.outcome != 'success' }} - uses: actions/upload-artifact@v7 - with: - retention-days: 30 - name: abicheck-candidate-failure-${{ env.ABICHECK_PROFILE }} - path: ${{ runner.temp }}/abicheck-candidate-failure - if-no-files-found: error + echo "::warning::ABI capture did not complete (outcome: ${{ steps.abi-capture.outcome }}); the comparison job will report an incomplete analysis, not a clean result." # ── ABI/API comparison (advisory; ABICC stays authoritative) ──────────── # From 2f9c26971e24b006a9ab286daca2bc2281cde6df Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 05:01:26 +0000 Subject: [PATCH 25/44] ci: make the explicit baseline overwrite path actually work Two review findings, both confirmed against the code. gh release upload refuses an asset name that already exists unless --clobber is passed. The overwrite branch logged its warning and then fell through to an upload without it, so "overwrite: true" failed the job instead of replacing the asset -- the bootstrap dispatch input could not do what it offered. --clobber is now passed on that branch only; the default path still leaves a published baseline untouched, which was the point of removing the blanket --clobber in the first place. abicheck-inputs.sh pointed readers at .github/workflows/abicheck-analyse.yml for the ownership rules. That file never existed on this branch -- it was a name from an earlier draft -- so the reference now points at documentation/abicheck.md, which is where those rules actually live. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-inputs.sh | 2 +- .github/actions/abicheck-publish-baseline/action.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.ci-local/abicheck-inputs.sh b/.ci-local/abicheck-inputs.sh index fa1ee2790..3456a5781 100755 --- a/.ci-local/abicheck-inputs.sh +++ b/.ci-local/abicheck-inputs.sh @@ -5,7 +5,7 @@ # shape, and prints `key=value` lines (also appended to $GITHUB_OUTPUT when # running under GitHub Actions). # -# Public-surface ownership (see .github/workflows/abicheck-analyse.yml): +# Public-surface ownership (see documentation/abicheck.md): # # libpvxs owns every installed include/pvxs/*.h except iochooks.h # libpvxsIoc owns include/pvxs/iochooks.h only; the core headers are diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml index cf276174a..54fdc2852 100644 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -89,7 +89,13 @@ runs: echo "::notice::$name is already published for $TAG; leaving the existing immutable asset in place" exit 0 fi + # gh refuses an asset name that already exists unless --clobber + # is given, so the explicit-overwrite path has to pass it. It is + # deliberately confined to this branch: the default path above + # never replaces a published baseline. echo "::warning::replacing the existing $name for $TAG at explicit request" + gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" --clobber + exit 0 fi gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" From 0beca2b6be13a6fd7fe2212d8d2b58ef94b5b016 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 05:23:51 +0000 Subject: [PATCH 26/44] ci: keep per-target detail reachable, and pin the reviewed publisher Rendering a real aggregate through the publisher's own renderer, with no credentials, surfaced two integration bugs that CI would have shipped. The expected-target manifest was written into the report directory that `abicheck aggregate` scans, so the manifest was itself picked up as a report. A two-component run declared "3 targets", one of them `expected-targets`, reported as outside the expected set. It is now written outside that directory. Aggregation ran with an absolute report-directory argument, so each target's recorded `report_path` was absolute. The publisher reads member reports only from the aggregate document's own directory and refuses an absolute path -- so every per-target detail was dropped and replaced by a limitation row, which reads exactly like a finding-free result. Aggregation now runs from inside the report directory, making each `report_path` a bare filename beside `aggregate.json`. Verified end to end in the CI shape: 2 targets instead of 3, no absolute-path refusals, and the analysed component's addition now counted ("2 safe" where it was "1") instead of being silently lost. The publisher is pinned to abicheck 9bc92e635fbfae3252189d3bd09059cc28599490, the head of abicheck PR #1311, replacing the earlier branch-tip pin. The caller's inputs and outputs were re-validated against that revision's declared schemas. The pin is immutable but the PR is not merged and its own CI had not finished, which documentation/abicheck.md now states. That revision also carries the fix for the persistent-hygiene bug reported from this branch: the rendered comment separates "339 pre-existing cross-source hygiene findings present on both sides -- not introduced by this change" from what the change actually introduced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-collect.sh | 13 ++++++++----- .github/workflows/abicheck-report.yml | 4 ++-- .github/workflows/ci-scripts-build.yml | 16 +++++++++++----- documentation/abicheck.md | 17 ++++++++++------- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.ci-local/abicheck-collect.sh b/.ci-local/abicheck-collect.sh index 86ff85b1b..3fac80194 100755 --- a/.ci-local/abicheck-collect.sh +++ b/.ci-local/abicheck-collect.sh @@ -2,6 +2,10 @@ # Collect the check reports this run produced and declare the set of checks # it was supposed to produce. # +# The expected-target manifest is deliberately written OUTSIDE the report +# directory: `abicheck aggregate` scans that directory for reports, and a +# manifest sitting in it is picked up as an extra, unexpected target. +# # Every check is named explicitly by the caller as `=`. # An empty path means "this check was expected but produced no report": it is # recorded in the expected-target manifest and deliberately left absent from @@ -10,11 +14,10 @@ # no exit code is interpreted in this script. set -eu -DEST=${1:?usage: abicheck-collect.sh =...} -shift -mkdir -p "$DEST" - -MANIFEST="$DEST/expected-targets.json" +DEST=${1:?usage: abicheck-collect.sh =...} +MANIFEST=${2:?usage: abicheck-collect.sh =...} +shift 2 +mkdir -p "$DEST" "$(dirname "$MANIFEST")" : > "$DEST/.collect-index" for spec in "$@"; do id=${spec%%=*} diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 3b55d466a..b311c077e 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # never inferred is a compatibility outcome from the run's conclusion. - name: Verify the producer run and fetch its reports id: source - uses: abicheck/abicheck/actions/verify-source-run@cb6102d85eaec62874f311646223cfbc1ef3cb25 + uses: abicheck/abicheck/actions/verify-source-run@9bc92e635fbfae3252189d3bd09059cc28599490 with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -98,7 +98,7 @@ jobs: # the other. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@cb6102d85eaec62874f311646223cfbc1ef3cb25 + uses: abicheck/abicheck/actions/report@9bc92e635fbfae3252189d3bd09059cc28599490 with: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index c7646ee80..9eec3868f 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -340,6 +340,7 @@ jobs: env: ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 REPORT_DIR: ${{ github.workspace }}/abicheck-reports + EXPECTED_TARGETS: ${{ runner.temp }}/abicheck-expected-targets.json CANDIDATE_DIR: ${{ github.workspace }}/abicheck-candidate BASELINE_RELEASE_DIR: ${{ github.workspace }}/abicheck-baseline-release BASELINE_MAIN_DIR: ${{ github.workspace }}/abicheck-baseline-main @@ -551,7 +552,7 @@ jobs: checks="$checks ${{ steps.rel-core.outputs.check-id || format('libpvxs@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-core.outputs.report-path }}" checks="$checks ${{ steps.rel-ioc.outputs.check-id || format('libpvxsIoc@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-ioc.outputs.report-path }}" # shellcheck disable=SC2086 - ./.ci-local/abicheck-collect.sh "$REPORT_DIR" $checks + ./.ci-local/abicheck-collect.sh "$REPORT_DIR" "$EXPECTED_TARGETS" $checks # One aggregate document over both components and both channels. The # publisher renders exactly this; it never re-runs a comparison and @@ -577,11 +578,16 @@ jobs: # decision (0 pass, 1 coverage/quality, 2 API break, 4 ABI break), # and this shadow check's gate is advisory -- so a non-zero code is # recorded, not swallowed. 64 is a usage error and is ours to fix. + # Run from inside the report directory so each target's recorded + # report_path is a bare filename beside aggregate.json. The + # publisher reads member reports only from the aggregate document's + # own directory and refuses an absolute path, so aggregating with an + # absolute directory argument silently costs every per-target detail. rc=0 - abicheck aggregate "$REPORT_DIR" \ - --manifest "$REPORT_DIR/expected-targets.json" \ - -o "json=$REPORT_DIR/aggregate.json" \ - -o "text=$REPORT_DIR/aggregate.txt" || rc=$? + ( cd "$REPORT_DIR" && abicheck aggregate . \ + --manifest "$EXPECTED_TARGETS" \ + -o "json=aggregate.json" \ + -o "text=aggregate.txt" ) || rc=$? echo "exit-code=$rc" >> "$GITHUB_OUTPUT" if [ "$rc" = "64" ]; then echo "::error::abicheck aggregate rejected its inputs (usage error)" diff --git a/documentation/abicheck.md b/documentation/abicheck.md index ffc84ecee..65cafc946 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -180,14 +180,17 @@ prominently reporting a detected break. ABICC remains authoritative. ## Dependency status -The publisher is pinned to abicheck `cb6102d85eaec62874f311646223cfbc1ef3cb25`, +The publisher is pinned to abicheck `9bc92e635fbfae3252189d3bd09059cc28599490`, which carries `actions/verify-source-run` and `actions/report`. That -revision is immutable but **not yet reviewed or merged** — it is a branch -tip awaiting its own pull request in abicheck. The PVXS caller's inputs -have been checked against that revision's declared schema, but no deployed -publication has been demonstrated, because `workflow_run` only runs the -default-branch copy of the publisher. This must not go upstream on an -unreviewed pin. +revision is immutable and is the head of abicheck PR #1311, which is open +for review but **not yet merged**, and whose own CI had not finished when +this pin was taken. The PVXS caller's inputs and outputs have been checked +against that revision's declared schemas, but no deployed publication has +been demonstrated, because `workflow_run` only runs the default-branch copy +of the publisher. + +This is a temporary pin on an unmerged revision. Before this goes upstream +it must be moved to the merged abicheck commit. ## Known issues (abicheck product bugs, measured 2026-09-16) From 51b33f0985fd1b88c2777db373eee931cf208f31 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 06:30:11 +0000 Subject: [PATCH 27/44] ci: repair the workflow file broken by an invalid env context 0beca2b set the abicheck job's EXPECTED_TARGETS from ${{ runner.temp }} in the job's `env:` block. The `runner` context is not available there -- only github, needs, strategy, matrix, vars, inputs and secrets are -- so the whole workflow file became invalid. GitHub failed the run instantly with no jobs at all, listing it by path rather than by its `name:`, which is what that failure looks like. The manifest path moves to ${{ github.workspace }}, which is valid in a job env block and is still outside REPORT_DIR, so `abicheck aggregate` still does not pick the manifest up as an extra target. YAML well-formedness was never the issue and parsing the file locally never would have caught this; the check that does is whether each expression's context is permitted at the level it appears. Verified by scanning every job's env/if/concurrency for `runner.` before pushing, and by re-running the collect/aggregate/validate chain in the corrected layout: 2 targets, relative report_path, artifact carrying only the reports and the aggregate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/workflows/ci-scripts-build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 9eec3868f..fcf786f87 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -340,7 +340,12 @@ jobs: env: ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 REPORT_DIR: ${{ github.workspace }}/abicheck-reports - EXPECTED_TARGETS: ${{ runner.temp }}/abicheck-expected-targets.json + # Outside REPORT_DIR on purpose -- `abicheck aggregate` scans that + # directory, and a manifest inside it is picked up as an extra target. + # Not runner.temp: the `runner` context is not available in a job's + # env block, and referencing it there makes the whole workflow file + # invalid (the run fails instantly with no jobs). + EXPECTED_TARGETS: ${{ github.workspace }}/abicheck-expected-targets.json CANDIDATE_DIR: ${{ github.workspace }}/abicheck-candidate BASELINE_RELEASE_DIR: ${{ github.workspace }}/abicheck-baseline-release BASELINE_MAIN_DIR: ${{ github.workspace }}/abicheck-baseline-main From 440fa8ebe9a763bfb85d7b7d762347fd11930e0b Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 16 Sep 2026 09:37:00 +0000 Subject: [PATCH 28/44] ci: order the sticky comment by the producer run, and refresh the pin Two findings from re-checking the publisher dependency. actions/report grew source-run-id and source-run-attempt, and this caller was not passing them. Left unset they default to the PUBLISHER's own run id, which orders by when publication was triggered rather than when the analysis ran -- so a late re-run of an older commit would look newer than the current result and overwrite it. That is the precise inversion the ordering guard exists to prevent, and it is the lifecycle property this integration claims to have. Both are now passed from the workflow_run event. The pin moves from 9bc92e6 to 2324460, six commits later on the same unmerged PR. Those commits fix defects in the parts this integration depends on: the PR-head/merge-commit association check, what a refused artifact leaves behind, member-report work bounds, shell-value handling, and an eleven-defect review round. Remaining on the older revision would have meant pinning to known-defective run-selection code -- the security boundary of this whole design. Still an unmerged revision, which documentation/abicheck.md continues to say. The caller's inputs and outputs re-validate against the new revision's schemas, and the render path still produces the same comment from a real aggregate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/workflows/abicheck-report.yml | 12 ++++++++++-- documentation/abicheck.md | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index b311c077e..29e5ff13d 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # never inferred is a compatibility outcome from the run's conclusion. - name: Verify the producer run and fetch its reports id: source - uses: abicheck/abicheck/actions/verify-source-run@9bc92e635fbfae3252189d3bd09059cc28599490 + uses: abicheck/abicheck/actions/verify-source-run@2324460fd3c2276f12cbce657b3ca00ad1d1449d with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -98,13 +98,21 @@ jobs: # the other. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@9bc92e635fbfae3252189d3bd09059cc28599490 + uses: abicheck/abicheck/actions/report@2324460fd3c2276f12cbce657b3ca00ad1d1449d with: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} pr-number: ${{ steps.source.outputs.pr-number }} sha: ${{ steps.source.outputs.tested-sha }} profile: ${{ env.ABICHECK_PROFILE }} + # Order the sticky comment by the PRODUCER's run, not this + # publisher's. Left unset these default to the publisher's own run + # id, which orders by when publication was triggered -- so a late + # re-run of an older commit would look newer than the current + # result and overwrite it, which is the inversion the guard exists + # to prevent. + source-run-id: ${{ github.event.workflow_run.id }} + source-run-attempt: ${{ github.event.workflow_run.run_attempt }} post-on: changes detail: standard run-label: >- diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 65cafc946..ba9c5f2c2 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -144,7 +144,10 @@ made against it. is the ephemeral merge commit, not the PR head, and the comment shows the analysed one. They are never substituted for each other. * Publication is bound to the producer attempt that triggered it, and - concurrency is keyed per pull request and profile. + concurrency is keyed per pull request and profile. The sticky comment's + ordering guard is given the *producer's* run id and attempt, not the + publisher's: ordering by the publisher would let a late re-run of an + older commit overwrite a newer result. * It never checks out, installs, imports or executes pull-request code or pull-request-built binaries, and it runs no analysis. * A publication failure fails visibly and is never reported as a clean @@ -180,11 +183,16 @@ prominently reporting a detected break. ABICC remains authoritative. ## Dependency status -The publisher is pinned to abicheck `9bc92e635fbfae3252189d3bd09059cc28599490`, +The publisher is pinned to abicheck `2324460fd3c2276f12cbce657b3ca00ad1d1449d`, which carries `actions/verify-source-run` and `actions/report`. That revision is immutable and is the head of abicheck PR #1311, which is open -for review but **not yet merged**, and whose own CI had not finished when -this pin was taken. The PVXS caller's inputs and outputs have been checked +for review but **not yet merged**. + +The pin was moved here from an earlier revision of the same PR because six +intervening commits fixed defects in the parts this integration depends on +— among them the PR-head/merge-commit association check, the behaviour of a +refused artifact, and shell-value handling. Staying on the older revision +would have meant pinning to known-defective run-selection code. The PVXS caller's inputs and outputs have been checked against that revision's declared schemas, but no deployed publication has been demonstrated, because `workflow_run` only runs the default-branch copy of the publisher. From 65d3f8d1416b2a916188500c952628029d631713 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:47:12 +0000 Subject: [PATCH 29/44] ci: pin the whole integration to the merged abicheck revision abicheck PR #1311 merged as bc2ee0c, so the temporary pin on an unmerged revision is gone. Every Action and the analysis package now name one commit instead of two, which also removes the split where the publisher and the analysis ran different abicheck revisions. The move was checked rather than assumed, in two parts. The five Action definitions this caller uses -- report, verify-source-run, baseline, check-target, stage-baseline -- are byte-identical between the previously pinned revisions and bc2ee0c, so no interface changed. The analysis package is not identical: bc2ee0c carries four later fixes, among them sided --header/--include resolution and the demotion of binary churn on exports no public header declares. Both components were therefore re-captured at --depth headers on bc2ee0c and re-aggregated -- schema 1.11, status pass, coverage complete, 2/2 analyzed -- and the PR comment was rendered from that real aggregate. The Known issues section is re-measured rather than carried forward. The merged revision does bound the damage: an unchanged pair now folds to zero gating findings and the comment labels the 845 hygiene findings as pre-existing on both sides. The attribution itself is still wrong -- EPICS Base and libstdc++ symbols reached only through -I roots are still listed as libpvxs's own, and pvxs::version_* as libpvxsIoc's -- so the section says what improved and what did not, and the integration stays advisory. Also recorded: one dump failed the CastXML version probe against the same 0.7.0 binary that a probe and the next dump both accepted, once, not reproducible. Noted as a flake to recognise, not worked around. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/actions/abicheck-capture/action.yml | 2 +- .../abicheck-publish-baseline/action.yml | 2 +- .github/workflows/abicheck-baseline.yml | 2 +- .github/workflows/abicheck-report.yml | 4 +- .github/workflows/ci-scripts-build.yml | 14 +-- documentation/abicheck.md | 100 +++++++++++------- 6 files changed, 75 insertions(+), 49 deletions(-) diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index 68c1acac0..d6658ffdd 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -59,7 +59,7 @@ runs: - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: capture - uses: abicheck/abicheck/actions/baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/baseline@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: libraries: ${{ steps.inputs.outputs.libraries }} output-dir: ${{ inputs.output-dir }} diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml index 54fdc2852..d173b3788 100644 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -57,7 +57,7 @@ runs: - name: Package the baseline-set id: stage - uses: abicheck/abicheck/actions/stage-baseline@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/stage-baseline@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: baseline-path: ${{ inputs.baseline-path }} asset-name-template: 'abicheck-baseline-{profile}.tar.zst' diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 7f9617cf0..509cfeb90 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -48,7 +48,7 @@ permissions: env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + ABICHECK_REF: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb SETUP_PATH: .ci-local CMP: gcc BCFG: default diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 29e5ff13d..dfbf2d3dc 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # never inferred is a compatibility outcome from the run's conclusion. - name: Verify the producer run and fetch its reports id: source - uses: abicheck/abicheck/actions/verify-source-run@2324460fd3c2276f12cbce657b3ca00ad1d1449d + uses: abicheck/abicheck/actions/verify-source-run@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -98,7 +98,7 @@ jobs: # the other. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@2324460fd3c2276f12cbce657b3ca00ad1d1449d + uses: abicheck/abicheck/actions/report@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index fcf786f87..f3dc6605f 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -263,7 +263,7 @@ jobs: # pull_request this is the merge commit, NOT the PR head. project-ref: ${{ github.sha }} profile: ${{ env.ABICHECK_PROFILE }} - abicheck-ref: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + abicheck-ref: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb - name: Record analysis context id: abi-context @@ -294,7 +294,7 @@ jobs: "pr_base_sha": os.environ.get("PR_BASE_SHA") or None, "pr_base_ref": os.environ.get("PR_BASE_REF") or None, "profile": os.environ["ABICHECK_PROFILE"], - "abicheck_ref": "3737f9f960ae14a7b24dbb6e9b686e49fb563673", + "abicheck_ref": "bc2ee0cc76ec44ef989043f82c34c2b50b2555bb", } with open(os.environ["OUT"], "w") as f: json.dump(doc, f, indent=2, sort_keys=True) @@ -338,7 +338,7 @@ jobs: # resolving the accepted-main baseline. No write scope, no secrets. actions: read env: - ABICHECK_REF: 3737f9f960ae14a7b24dbb6e9b686e49fb563673 + ABICHECK_REF: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb REPORT_DIR: ${{ github.workspace }}/abicheck-reports # Outside REPORT_DIR on purpose -- `abicheck aggregate` scans that # directory, and a manifest inside it is picked up as an extra target. @@ -461,7 +461,7 @@ jobs: - name: 'libpvxs vs accepted-main' id: main-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -481,7 +481,7 @@ jobs: - name: 'libpvxsIoc vs accepted-main' id: main-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -501,7 +501,7 @@ jobs: - name: 'libpvxs vs release-contract' id: rel-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -520,7 +520,7 @@ jobs: - name: 'libpvxsIoc vs release-contract' id: rel-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@3737f9f960ae14a7b24dbb6e9b686e49fb563673 + uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} diff --git a/documentation/abicheck.md b/documentation/abicheck.md index ba9c5f2c2..5db188400 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -183,40 +183,66 @@ prominently reporting a detected break. ABICC remains authoritative. ## Dependency status -The publisher is pinned to abicheck `2324460fd3c2276f12cbce657b3ca00ad1d1449d`, -which carries `actions/verify-source-run` and `actions/report`. That -revision is immutable and is the head of abicheck PR #1311, which is open -for review but **not yet merged**. - -The pin was moved here from an earlier revision of the same PR because six -intervening commits fixed defects in the parts this integration depends on -— among them the PR-head/merge-commit association check, the behaviour of a -refused artifact, and shell-value handling. Staying on the older revision -would have meant pinning to known-defective run-selection code. The PVXS caller's inputs and outputs have been checked -against that revision's declared schemas, but no deployed publication has -been demonstrated, because `workflow_run` only runs the default-branch copy -of the publisher. - -This is a temporary pin on an unmerged revision. Before this goes upstream -it must be moved to the merged abicheck commit. - -## Known issues (abicheck product bugs, measured 2026-09-16) - -Reproduced on this branch with abicheck -`3737f9f960ae14a7b24dbb6e9b686e49fb563673` and CastXML 0.7.0, by comparing a -snapshot against itself (a byte-identical pair, verdict `NO_CHANGE`): - -1. **Include-context headers become export obligations.** 123 EPICS Base - symbols (`epicsMutex::lock()`, `epicsEvent::wait()`, `errVerbose`, …) are - charged to `libpvxs` as its own missing exports, and `pvxs::version_str()` - and friends are charged to `libpvxsIoc`. Both are reached only through - `-I` include roots. -2. **Persistent hygiene findings are reported as changes.** An unchanged - library reports 505 (`libpvxs`) and 339 (`libpvxsIoc`) findings, almost - all `exported_not_public` template guard variables that are identical on - both sides. - -Together these would make an unchanged pull request produce an 844-finding -comment, 126 of them false. Both are fixes owed by abicheck, not by -suppressions here. This integration must not be enabled for anything beyond -shadow reporting until they are fixed. +Every abicheck Action and the analysis package itself are pinned to one +merged revision, `bc2ee0cc76ec44ef989043f82c34c2b50b2555bb` on abicheck +`main` — the squash of abicheck PR #1311, which added +`actions/verify-source-run` and `actions/report`. Nothing here depends on +an unmerged revision any more. + +Two things were checked before moving the pin, not assumed: + +- `actions/report`, `actions/verify-source-run`, `actions/baseline`, + `actions/check-target` and `actions/stage-baseline` are byte-identical + between the revisions this branch previously pinned and `bc2ee0c`, so the + move changes no Action interface this caller depends on. +- The analysis package is not identical — `bc2ee0c` carries four later + fixes, among them input resolution for sided `--header`/`--include` + values and the demotion of binary churn on exports no public header + declares. Both components were therefore re-captured at `--depth headers` + on `bc2ee0c` and re-aggregated: schema `1.11`, `status: pass`, + `coverage: complete`, 2/2 targets `analyzed`, and the PR comment renders + from that aggregate. + +## Known issues (abicheck product bugs, re-measured 2026-09-16 on `bc2ee0c`) + +Re-measured on this branch with abicheck +`bc2ee0cc76ec44ef989043f82c34c2b50b2555bb` and CastXML 0.7.0, by comparing +each component's snapshot against itself — a byte-identical pair, where the +only correct answer is "no change". + +What the merged revision fixed: nothing these two produce now gates or is +presented as a change. Both self-comparisons return verdict `NO_CHANGE` +with `0` gating findings, and the rendered PR comment says so explicitly — +"♻️ 845 pre-existing cross-source hygiene findings present on both sides — +not introduced by this change", with an audit line reading `845 detected · +0 gating · 845 non gating`. + +What is still wrong, at the detection layer: + +1. **Include-context headers are still charged to the component.** EPICS + Base symbols (`epicsMutex::lock()`, `epicsEvent::wait()`, `errVerbose`, + …) and libstdc++ internals still appear in `libpvxs`'s own itemized + list, and `pvxs::version_str()`/`version_int()`/`version_abi_int()` — + declared in `pvxs/version.h`, which `libpvxs` owns — appear in + `libpvxsIoc`'s. All are reached only through `-I` include roots, not + through the component's own declared public surface. +2. **Persistent hygiene findings are still detected on an unchanged pair.** + An unchanged `libpvxs` detects 505 findings and `libpvxsIoc` 340, listed + as 505 and 340 "Modifications" in the per-component review rendering, + although both fold to zero gating findings. + +So the effect on a pull-request comment is now bounded and honestly +labelled, but the underlying attribution is still wrong and the full +per-component report is still 845 items of noise. Both remain fixes owed by +abicheck, not by suppressions here. This integration stays advisory until +they are fixed. + +### Capture flake worth watching + +One `abicheck dump` invocation in this re-measurement failed with "CastXML +of unknown version was found", from the same CastXML 0.7.0 binary that a +`--version` probe and an immediately following dump both accepted. It has +been seen once and did not reproduce on retry. If a capture leg fails that +way in CI, it is this, not a real toolchain problem — but the leg fails +loudly rather than degrading to "no findings", which is the intended +behaviour. From d2c3904ced6173b6a2baa66035d84adc57407bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 17:50:03 +0000 Subject: [PATCH 30/44] ci: hand the generic ABI machinery back to abicheck abicheck #1315 merged as 0b50f80, adding actions/aggregate, actions/verify-baseline-source, and library-spec resolution in actions/baseline. Those are the owners this integration was hand-rolling, so the hand-rolled versions go. Deleted outright, with the upstream owner named: abicheck-inputs.sh (126) actions/baseline library-spec abicheck-snapshots.sh (69) actions/resolve-baseline kind: members abicheck-collect.sh (63) actions/aggregate abicheck-validate-aggregate.sh (65) actions/aggregate That is every ELF inspection, manifest parse, report-identity rule and aggregate-document check PVXS was maintaining. Hand-written shell/python across the integration drops from 415 lines to 212. Zero abicheck .sh files remain. What replaces them is declaration. .ci-local/abicheck-components.json states the ownership split -- libpvxs owns every installed public header except iochooks.h, libpvxsIoc owns only iochooks.h, the core and EPICS trees are include context that never widens what a component owns -- and abicheck resolves it. Two assertions are kept deliberately rather than lost to a glob, because a pattern matching nothing is a hard error upstream: * versionNum.h is named explicitly as well as matched by *.h. Verified: with the glob alone a missing generated header resolves silently to 14 headers, which would report every versionNum declaration as removed. * header_exclude on iochooks.h fails if that header ever moves, so libpvxs cannot quietly re-acquire its sibling's declarations. Capture parity was checked before deleting anything, not assumed: the new resolver and the old script name the same two artifacts, the same 15/1 header split and the same four include roots on a real PVXS install, with machine EM_X86_64. Both guards above were fired against a disposable copy of that tree. The eligibility shell goes to verify-baseline-source: producer-run for the PR base, tag for the release channel. It keeps what the shell established -- refs/tags resolution rather than the commits endpoint, annotated-tag peeling, no v prefix, the capture must belong to the tagged commit, and not_found distinct from lookup_failed -- and adds printed rejection reasons. PVXS still fetches the bytes; only the decision moved. actions/aggregate replaces collect + aggregate + validate, including the || true. compatibility-exit carries aggregate's own 0/1/2/4 without failing the step, so the gate stays advisory, while a refused declaration or a document describing no real outcome fails loudly. Exercised on real reports: a half-declared run yields status=fail coverage=partial with channels split accepted-main 2/0 and release-contract 0/2, the old {"aggregate_schema_version":"nonsense"} bypass is refused, and a corrupted report is tracked as unusable rather than as one that never ran -- a distinction the deleted validator could not make. Known issues re-measured on 0b50f80 rather than carried forward: unchanged at 505/340 detected, 0 gating, NO_CHANGE. Not delivered upstream, so kept and disclosed: abicheck-publish-baseline has no upstream replacement yet -- publish-baseline.yml captures rather than publishing a pre-captured set. Extending it is the owed follow-up; a second publisher here would be the competing implementation this change exists to remove. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck-collect.sh | 63 ----- .ci-local/abicheck-components.json | 32 +++ .ci-local/abicheck-inputs.sh | 126 ---------- .ci-local/abicheck-snapshots.sh | 69 ------ .ci-local/abicheck-validate-aggregate.sh | 65 ------ .github/actions/abicheck-capture/action.yml | 90 +++++++- .../abicheck-publish-baseline/action.yml | 2 +- .github/workflows/abicheck-baseline.yml | 73 +++--- .github/workflows/abicheck-report.yml | 4 +- .github/workflows/ci-scripts-build.yml | 217 +++++++++--------- documentation/abicheck.md | 91 +++++--- 11 files changed, 319 insertions(+), 513 deletions(-) delete mode 100755 .ci-local/abicheck-collect.sh create mode 100644 .ci-local/abicheck-components.json delete mode 100755 .ci-local/abicheck-inputs.sh delete mode 100755 .ci-local/abicheck-snapshots.sh delete mode 100755 .ci-local/abicheck-validate-aggregate.sh diff --git a/.ci-local/abicheck-collect.sh b/.ci-local/abicheck-collect.sh deleted file mode 100755 index 3fac80194..000000000 --- a/.ci-local/abicheck-collect.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/sh -# Collect the check reports this run produced and declare the set of checks -# it was supposed to produce. -# -# The expected-target manifest is deliberately written OUTSIDE the report -# directory: `abicheck aggregate` scans that directory for reports, and a -# manifest sitting in it is picked up as an extra, unexpected target. -# -# Every check is named explicitly by the caller as `=`. -# An empty path means "this check was expected but produced no report": it is -# recorded in the expected-target manifest and deliberately left absent from -# the report directory, so `abicheck aggregate` reports it as an unavailable -# target. A missing analysis is never turned into a clean result here, and -# no exit code is interpreted in this script. -set -eu - -DEST=${1:?usage: abicheck-collect.sh =...} -MANIFEST=${2:?usage: abicheck-collect.sh =...} -shift 2 -mkdir -p "$DEST" "$(dirname "$MANIFEST")" -: > "$DEST/.collect-index" -for spec in "$@"; do - id=${spec%%=*} - path=${spec#*=} - [ -n "$id" ] || { echo "empty check id in '$spec'" >&2; exit 64; } - case "$id" in - */*|*..*) - echo "check id '$id' contains a path separator" >&2; exit 64 ;; - esac - if [ -n "$path" ] && [ -f "$path" ]; then - # abicheck aggregate matches a report to its expected target by the - # report's own "target_id" when it has one, and otherwise by the - # file stem after the "abi-report-" prefix. The check id is - # therefore written VERBATIM: sanitising its '@', '#' and '~' - # separators away would make the stem stop matching the expected - # id, and every report would silently aggregate as an unavailable - # target. - cp "$path" "$DEST/abi-report-$id.json" - printf '%s\tpresent\n' "$id" >> "$DEST/.collect-index" - else - printf '%s\tmissing\n' "$id" >> "$DEST/.collect-index" - echo "::warning::no report for check '$id'; it will aggregate as an unavailable target" - fi -done - -python3 - "$DEST/.collect-index" "$MANIFEST" <<'PY' -import json, sys - -index, out = sys.argv[1:3] -targets = [] -with open(index, encoding="utf-8") as fh: - for line in fh: - line = line.rstrip("\n") - if not line: - continue - check_id, _, _state = line.partition("\t") - targets.append({"id": check_id, "required": True}) - -with open(out, "w", encoding="utf-8") as fh: - json.dump({"targets": targets}, fh, indent=2, sort_keys=True) -print(f"declared {len(targets)} expected check(s) in {out}") -PY -rm -f "$DEST/.collect-index" diff --git a/.ci-local/abicheck-components.json b/.ci-local/abicheck-components.json new file mode 100644 index 000000000..e11e8c2ea --- /dev/null +++ b/.ci-local/abicheck-components.json @@ -0,0 +1,32 @@ +[ + { + "name": "libpvxs", + "artifact": "lib/${EPICS_HOST_ARCH}/libpvxs.so*", + "header": [ + "include/pvxs/*.h", + "include/pvxs/versionNum.h" + ], + "header_exclude": [ + "include/pvxs/iochooks.h" + ], + "include": [ + "include", + "${EPICS_BASE}/include", + "${EPICS_BASE}/include/os/Linux", + "${EPICS_BASE}/include/compiler/gcc" + ] + }, + { + "name": "libpvxsIoc", + "artifact": "lib/${EPICS_HOST_ARCH}/libpvxsIoc.so*", + "header": [ + "include/pvxs/iochooks.h" + ], + "include": [ + "include", + "${EPICS_BASE}/include", + "${EPICS_BASE}/include/os/Linux", + "${EPICS_BASE}/include/compiler/gcc" + ] + } +] diff --git a/.ci-local/abicheck-inputs.sh b/.ci-local/abicheck-inputs.sh deleted file mode 100755 index 3456a5781..000000000 --- a/.ci-local/abicheck-inputs.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/sh -# Resolve the inputs an ABI capture needs from an already-completed PVXS -# build. This script performs no build, no analysis and no reporting: it -# only names files the normal `cue.py build` installed, validates their -# shape, and prints `key=value` lines (also appended to $GITHUB_OUTPUT when -# running under GitHub Actions). -# -# Public-surface ownership (see documentation/abicheck.md): -# -# libpvxs owns every installed include/pvxs/*.h except iochooks.h -# libpvxsIoc owns include/pvxs/iochooks.h only; the core headers are -# include-only context for it. -# -# An include search root is context for the C++ parser, not a declaration -# that the component must export everything declared under it. -set -eu - -TOP=${1:-$PWD} -cd "$TOP" - -fail() { echo "abicheck-inputs: $*" >&2; exit 64; } - -# EPICS_BASE: use the already-prepared dependency. configure/RELEASE.local -# is where cue.py records it; we read it, we never rewrite it, and we never -# move HOME to rediscover it. -if [ -z "${EPICS_BASE:-}" ] && [ -f configure/RELEASE.local ]; then - EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' configure/RELEASE.local | tail -n 1) -fi -[ -n "${EPICS_BASE:-}" ] || fail "EPICS_BASE is not set and configure/RELEASE.local does not name it" -[ -d "$EPICS_BASE/include" ] || fail "EPICS_BASE=$EPICS_BASE has no include/ tree; run 'cue.py prepare' first" - -ARCH=${EPICS_HOST_ARCH:-} -if [ -z "$ARCH" ]; then - [ -x "$EPICS_BASE/startup/EpicsHostArch" ] || fail "cannot determine EPICS_HOST_ARCH" - ARCH=$("$EPICS_BASE/startup/EpicsHostArch") -fi -case "$ARCH" in - linux-*) ;; - *) fail "this capture is declared for a native Linux host arch, got '$ARCH'" ;; -esac - -LIBDIR="$TOP/lib/$ARCH" -[ -d "$LIBDIR" ] || fail "no installed library directory $LIBDIR; was 'cue.py build' run?" - -# Resolve exactly one real shared object per component. Symlinks (the -# unversioned/SONAME aliases) are deliberately excluded so they are not -# analysed as duplicate components, and static archives are never eligible. -resolve_dso() { - _name=$1 - _matches=$(find "$LIBDIR" -maxdepth 1 -type f -name "$_name.so.*" -print | LC_ALL=C sort) - _count=$(printf '%s\n' "$_matches" | sed '/^$/d' | wc -l) - [ "$_count" -eq 1 ] || fail "expected exactly one $_name shared object in $LIBDIR, found $_count" - _so=$_matches - head -c 4 "$_so" | grep -q 'ELF' || fail "$_so is not an ELF object" - _hdr=$(readelf -h "$_so" 2>/dev/null) || fail "cannot read ELF header of $_so" - printf '%s\n' "$_hdr" | grep -q 'Type:[[:space:]]*DYN' || fail "$_so is not a shared object (DYN)" - printf '%s\n' "$_so" -} - -LIBPVXS=$(resolve_dso libpvxs) -LIBPVXSIOC=$(resolve_dso libpvxsIoc) - -MACHINE=$(readelf -h "$LIBPVXS" | sed -n -E 's/^[[:space:]]*Machine:[[:space:]]*//p') -IOC_MACHINE=$(readelf -h "$LIBPVXSIOC" | sed -n -E 's/^[[:space:]]*Machine:[[:space:]]*//p') -[ "$MACHINE" = "$IOC_MACHINE" ] || fail "libpvxs ($MACHINE) and libpvxsIoc ($IOC_MACHINE) disagree on target machine" - -INCROOT="$TOP/include" -[ -d "$INCROOT/pvxs" ] || fail "no installed header tree $INCROOT/pvxs" -IOC_HEADER="$INCROOT/pvxs/iochooks.h" -[ -f "$IOC_HEADER" ] || fail "libpvxsIoc's only public header $IOC_HEADER was not installed" - -# Generated public headers (versionNum.h) are installed alongside the -# checked-in ones; both belong to libpvxs' owned surface. -[ -f "$INCROOT/pvxs/versionNum.h" ] || fail "generated public header versionNum.h was not installed" - -CORE_HEADERS=$(find "$INCROOT/pvxs" -maxdepth 1 -type f -name '*.h' ! -name 'iochooks.h' -print | LC_ALL=C sort | tr '\n' ' ') -[ -n "$CORE_HEADERS" ] || fail "no installed core PVXS headers below $INCROOT/pvxs" -CORE_HEADERS=${CORE_HEADERS% } - -# Include-only context shared by both components. The EPICS dependency -# needs its normal include root plus the OS and compiler sub-roots. -INCLUDES="$INCROOT $EPICS_BASE/include $EPICS_BASE/include/os/Linux $EPICS_BASE/include/compiler/gcc" -for d in $INCLUDES; do - [ -d "$d" ] || fail "include root $d does not exist" -done - -emit() { - printf '%s=%s\n' "$1" "$2" - [ -z "${GITHUB_OUTPUT:-}" ] || printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT" -} - -emit host-arch "$ARCH" -emit machine "$MACHINE" -emit epics-base "$EPICS_BASE" -emit libpvxs "$LIBPVXS" -emit libpvxsioc "$LIBPVXSIOC" -emit core-headers "$CORE_HEADERS" -emit ioc-headers "$IOC_HEADER" -emit includes "$INCLUDES" - -# The capture Action (abicheck actions/baseline) takes one JSON array -# describing every library to dump. Building it here keeps the ownership -# declaration in one place instead of duplicating the header lists in YAML. -LIBRARIES=$(CORE_HEADERS="$CORE_HEADERS" IOC_HEADER="$IOC_HEADER" \ - INCLUDES="$INCLUDES" LIBPVXS="$LIBPVXS" LIBPVXSIOC="$LIBPVXSIOC" \ - python3 -c ' -import json, os -print(json.dumps([ - { - # libpvxs owns every installed public header except the IOC one. - "name": "libpvxs", - "artifact": os.environ["LIBPVXS"], - "header": os.environ["CORE_HEADERS"], - "include": os.environ["INCLUDES"], - }, - { - # libpvxsIoc owns exactly one header; the core and EPICS headers - # are include-only context, not an export obligation. - "name": "libpvxsIoc", - "artifact": os.environ["LIBPVXSIOC"], - "header": os.environ["IOC_HEADER"], - "include": os.environ["INCLUDES"], - }, -], separators=(",", ":"))) -') -emit libraries "$LIBRARIES" diff --git a/.ci-local/abicheck-snapshots.sh b/.ci-local/abicheck-snapshots.sh deleted file mode 100755 index dfa57958c..000000000 --- a/.ci-local/abicheck-snapshots.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/sh -# Name the candidate snapshot each component's comparison must use. -# -# The capture step wrote a baseline-set: one snapshot per library plus a -# manifest.json recording their identities and digests. This reads that -# manifest -- it does not guess filenames, and it does not pick whichever -# file happens to sort first. -# -# Note the manifest's two different path fields: "artifact" echoes the input -# binary the producing job dumped (an absolute path in that job's workspace, -# meaningless here), while "snapshot" is the snapshot file's own name inside -# the baseline-set. Only the latter is usable after the set has been moved -# between jobs. -set -eu - -DIR=${1:?usage: abicheck-snapshots.sh } -MANIFEST="$DIR/manifest.json" -[ -f "$MANIFEST" ] || { echo "no manifest.json in $DIR" >&2; exit 64; } - -DIR="$DIR" python3 - "$MANIFEST" <<'PY' -import json -import os -import sys - -manifest = json.load(open(sys.argv[1], encoding="utf-8")) -root = os.path.realpath(os.environ["DIR"]) -wanted = {"libpvxs": "libpvxs", "libpvxsIoc": "libpvxsioc"} - -found = {} -for entry in manifest.get("artifacts", []): - name = entry.get("library") - if name not in wanted: - continue - rel = entry.get("snapshot") - if not rel: - raise SystemExit(f"manifest entry for {name} names no snapshot file") - path = os.path.realpath(os.path.join(root, rel)) - # The manifest is produced by the same run, but it travels as an - # artifact, so treat it as data: a snapshot must be a regular file - # inside the baseline-set, not a link out of it. - if os.path.commonpath([root, path]) != root: - raise SystemExit(f"snapshot path for {name} escapes the baseline-set: {rel}") - if not os.path.isfile(path): - raise SystemExit(f"snapshot for {name} is missing: {path}") - - # Content identity is deliberately NOT re-checked here. The manifest's - # per-artifact sha256 is abicheck's own normalised content hash, not a - # whole-file digest, and reimplementing that recipe here would be a - # second, silently-divergent verifier. resolve-baseline (invoked by - # check-target for the baseline side) is the component that validates - # baseline-set identity. - if name in found: - # Two entries for one component is an ambiguous set, not a - # last-one-wins choice: silently overwriting would pick a snapshot - # nobody selected. - raise SystemExit(f"baseline-set declares {name} more than once") - found[name] = path - -missing = sorted(set(wanted) - set(found)) -if missing: - raise SystemExit("baseline-set has no snapshot for: " + ", ".join(missing)) - -lines = [f"{wanted[name]}={path}" for name, path in sorted(found.items())] -out = os.environ.get("GITHUB_OUTPUT") -if out: - with open(out, "a", encoding="utf-8") as fh: - fh.write("\n".join(lines) + "\n") -print("\n".join(lines)) -PY diff --git a/.ci-local/abicheck-validate-aggregate.sh b/.ci-local/abicheck-validate-aggregate.sh deleted file mode 100755 index 94c1477e0..000000000 --- a/.ci-local/abicheck-validate-aggregate.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/sh -# Validate that a file really is an abicheck aggregate outcome document -# before anything downstream treats it as one. -# -# Checking that a single key is present is not validation: a document that -# merely carries an "aggregate_schema_version" string would pass while -# describing nothing. Operational report loss must not be able to reach the -# publisher disguised as an empty finding set. -set -eu - -DOC=${1:?usage: abicheck-validate-aggregate.sh } -[ -s "$DOC" ] || { echo "aggregate document $DOC is missing or empty" >&2; exit 1; } - -python3 - "$DOC" <<'PY' -import json -import sys - -path = sys.argv[1] -try: - doc = json.load(open(path, encoding="utf-8")) -except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"aggregate document is not readable JSON: {exc}") - -if not isinstance(doc, dict): - raise SystemExit("aggregate document is not a JSON object") - -version = doc.get("aggregate_schema_version") -if not isinstance(version, str) or not version.strip(): - raise SystemExit("aggregate document declares no schema version") - -if doc.get("status") not in ("pass", "fail"): - raise SystemExit(f"aggregate status is not pass/fail: {doc.get('status')!r}") - -for block in ("compatibility", "coverage", "gate"): - if not isinstance(doc.get(block), dict): - raise SystemExit(f"aggregate document has no {block} block") - -coverage = doc["coverage"] -# abicheck.workflows.aggregate.contracts.CoverageStatus -if coverage.get("status") not in ("complete", "partial", "empty"): - raise SystemExit(f"coverage status is not a CoverageStatus value: {coverage.get('status')!r}") - -targets = doc.get("targets") -if not isinstance(targets, list): - raise SystemExit("aggregate document has no targets list") -if not targets: - # Zero targets means the expected set never reached aggregation. That is - # an operational failure of the producing job, not a clean comparison. - raise SystemExit("aggregate document declares no targets at all") - -for target in targets: - if not isinstance(target, dict) or not target.get("target_id"): - raise SystemExit("aggregate document has a target with no target_id") - if target.get("state") not in ("analyzed", "unavailable"): - raise SystemExit( - f"target {target.get('target_id')!r} has an unrecognised state " - f"{target.get('state')!r}" - ) - -analyzed = sum(1 for t in targets if t.get("state") == "analyzed") -print( - f"aggregate ok: schema {version}, status {doc['status']}, " - f"coverage {coverage['status']}, {analyzed}/{len(targets)} target(s) analyzed" -) -PY diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index d6658ffdd..dd65c20c3 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -2,9 +2,15 @@ name: 'PVXS ABI capture' description: >- Capture ABI/API evidence for libpvxs and libpvxsIoc from an installation that a normal PVXS build has already produced. Performs no build, no - comparison and no reporting: it resolves the installed headers and shared - objects, then calls abicheck's own baseline Action once for both - components. + comparison and no reporting. + + What is PVXS-specific lives here: where the EPICS dependency was prepared + and which host architecture it built for. Everything else -- selecting the + real shared object out of its SONAME alias chain, checking it is an ELF + ET_DYN, agreeing the target machine between components, expanding the owned + header sets and refusing a stale exclusion -- belongs to abicheck's own + baseline Action, which reads the component declaration in + .ci-local/abicheck-components.json. Used by the selected matrix leg of ci-scripts-build.yml (the candidate capture) and by abicheck-baseline.yml's one-time historical bootstrap, so @@ -13,9 +19,9 @@ description: >- inputs: top: description: > - Root of the built PVXS tree to capture from. Defaults to the current - working directory; the historical bootstrap builds into its own - directory and points this at it. + Root of the built PVXS tree to capture from. Defaults to the workspace; + the historical bootstrap builds into its own directory and points this + at it. required: false default: '' output-dir: @@ -37,6 +43,10 @@ inputs: description: 'Path to the abicheck extraction config.' required: false default: '.ci-local/abicheck.yml' + component-spec: + description: 'Path to the PVXS component declaration.' + required: false + default: '.ci-local/abicheck-components.json' abicheck-ref: description: 'Immutable abicheck revision the capture runs from.' required: true @@ -48,20 +58,78 @@ outputs: content-digest: description: "Digest over the manifest's artifact list." value: ${{ steps.capture.outputs.content-digest }} + resolved-libraries: + description: 'The concrete artifact/header/include set that was dumped.' + value: ${{ steps.capture.outputs.resolved-libraries }} + machine: + description: 'The ELF machine both components agreed on (e.g. EM_X86_64).' + value: ${{ steps.capture.outputs.machine }} runs: using: 'composite' steps: - - name: Resolve ABI capture inputs - id: inputs + # The only project-specific knowledge left: where cue.py put EPICS Base + # and which host arch it built for. These are build-system facts, not + # ABI facts, so abicheck deliberately hard-codes neither. + - name: Resolve EPICS build context + id: epics shell: bash - run: ./.ci-local/abicheck-inputs.sh ${{ inputs.top }} + env: + TOP: ${{ inputs.top || github.workspace }} + SPEC_IN: ${{ inputs.component-spec }} + SPEC_OUT: ${{ runner.temp }}/abicheck-components.resolved.json + run: | + set -eu + cd "$TOP" + + # configure/RELEASE.local is where cue.py records the prepared + # dependency. We read it; we never rewrite it. + if [ -z "${EPICS_BASE:-}" ] && [ -f configure/RELEASE.local ]; then + EPICS_BASE=$(sed -n -E 's/^[[:space:]]*EPICS_BASE[[:space:]]*=[[:space:]]*//p' \ + configure/RELEASE.local | tail -n 1) + fi + [ -n "${EPICS_BASE:-}" ] || { + echo "::error::EPICS_BASE is not set and configure/RELEASE.local does not name it" + exit 64 + } + + EPICS_HOST_ARCH=${EPICS_HOST_ARCH:-} + if [ -z "$EPICS_HOST_ARCH" ] && [ -x "$EPICS_BASE/startup/EpicsHostArch" ]; then + EPICS_HOST_ARCH=$("$EPICS_BASE/startup/EpicsHostArch") + fi + case "$EPICS_HOST_ARCH" in + linux-*) ;; + *) + echo "::error::this capture is declared for a native Linux host arch, got '${EPICS_HOST_ARCH:-}'" + exit 64 + ;; + esac + + # Render the two build-system values into the declaration. Exactly + # these two placeholders are substituted -- not envsubst, which is + # not guaranteed on every runner image and would also expand any + # other '$' the declaration might legitimately contain. + sed -e "s|\${EPICS_BASE}|$EPICS_BASE|g" \ + -e "s|\${EPICS_HOST_ARCH}|$EPICS_HOST_ARCH|g" \ + "$SPEC_IN" > "$SPEC_OUT" + if grep -q '\${' "$SPEC_OUT"; then + echo "::error::unsubstituted placeholder left in $SPEC_OUT" + grep -n '\${' "$SPEC_OUT" >&2 + exit 64 + fi + + echo "epics-base=$EPICS_BASE" >> "$GITHUB_OUTPUT" + echo "host-arch=$EPICS_HOST_ARCH" >> "$GITHUB_OUTPUT" + echo "spec=$SPEC_OUT" >> "$GITHUB_OUTPUT" + echo "resolved component declaration:" + cat "$SPEC_OUT" - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: capture - uses: abicheck/abicheck/actions/baseline@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: - libraries: ${{ steps.inputs.outputs.libraries }} + library-spec: ${{ steps.epics.outputs.spec }} + library-root: ${{ inputs.top || github.workspace }} output-dir: ${{ inputs.output-dir }} project-ref: ${{ inputs.project-ref }} profile: ${{ inputs.profile }} diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml index d173b3788..25622562a 100644 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -57,7 +57,7 @@ runs: - name: Package the baseline-set id: stage - uses: abicheck/abicheck/actions/stage-baseline@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/stage-baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: baseline-path: ${{ inputs.baseline-path }} asset-name-template: 'abicheck-baseline-{profile}.tar.zst' diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 509cfeb90..774c32a81 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -48,7 +48,7 @@ permissions: env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - ABICHECK_REF: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + ABICHECK_REF: 0b50f807c8ea05719e414e78564c31ef32a2ea4e SETUP_PATH: .ci-local CMP: gcc BCFG: default @@ -72,54 +72,55 @@ jobs: with: persist-credentials: false - - name: Verify the producer run and that it built a real tag - id: source - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} + # Two questions, both answered by the shared verifier rather than by + # hand-written gh/jq here: + # 1. may a baseline be taken from this producer run at all? + # 2. is the name it built really a tag, and does that tag name the + # exact commit that was built? + # PVXS tags are bare versions ("1.5.2"), not "v"-prefixed; the verifier + # assumes no prefix and resolves refs/tags/ explicitly, peeling an + # annotated tag rather than treating the tag object as the commit. + - name: Verify the producer run + id: producer + uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + with: + mode: producer-run + workflow: .github/workflows/ci-scripts-build.yml + expect-event: push + expect-head-sha: ${{ github.event.workflow_run.head_sha }} + expect-head-branch: ${{ github.event.workflow_run.head_branch }} + + - name: Verify the tag identity + id: tag + if: ${{ steps.producer.outputs.eligible == 'true' }} + uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + with: + mode: tag + tag: ${{ github.event.workflow_run.head_branch }} + built-sha: ${{ github.event.workflow_run.head_sha }} + + - name: Note why nothing will be published + if: ${{ steps.producer.outputs.eligible != 'true' || steps.tag.outputs.eligible != 'true' }} run: | - set -euo pipefail - run_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") - test "$(jq -r '.repository.full_name' <<<"$run_json")" = "$GITHUB_REPOSITORY" - test "$(jq -r '.path' <<<"$run_json")" = ".github/workflows/ci-scripts-build.yml" - test "$(jq -r '.event' <<<"$run_json")" = "push" - test "$(jq -r '.conclusion' <<<"$run_json")" = "success" - head_sha=$(jq -r '.head_sha' <<<"$run_json") - tag=$(jq -r '.head_branch' <<<"$run_json") - - # PVXS tags are bare versions ("1.5.2"), not "v"-prefixed, so a - # name-shaped guard would exclude every real release. Instead prove - # the name IS a tag by resolving refs/tags/ explicitly -- the - # commits endpoint accepts any revision name and would happily - # resolve a branch. - if ! ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>/dev/null); then - echo "run $RUN_ID built '$tag', which is not a tag of this repository; nothing to publish" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - obj_sha=$(jq -r '.object.sha' <<<"$ref") - if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then - # Annotated tag: peel it to the commit it points at. - obj_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$obj_sha" --jq '.object.sha') - fi - test "$obj_sha" = "$head_sha" - { echo "run-id=$RUN_ID"; echo "tag=$tag"; echo "skip=false"; } >> "$GITHUB_OUTPUT" + echo "producer outcome: ${{ steps.producer.outputs.outcome || 'not evaluated' }}" + echo "tag outcome: ${{ steps.tag.outputs.outcome || 'not evaluated' }}" + echo "nothing to publish from this run." - uses: actions/download-artifact@v7 - if: ${{ steps.source.outputs.skip == 'false' }} + if: ${{ steps.tag.outputs.eligible == 'true' }} with: name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} path: ${{ runner.temp }}/baseline-set - run-id: ${{ steps.source.outputs.run-id }} + run-id: ${{ steps.producer.outputs.run-id }} github-token: ${{ github.token }} - name: Publish the baseline-set - if: ${{ steps.source.outputs.skip == 'false' }} + if: ${{ steps.tag.outputs.eligible == 'true' }} uses: ./.github/actions/abicheck-publish-baseline with: baseline-path: ${{ runner.temp }}/baseline-set profile: ${{ env.ABICHECK_PROFILE }} - tag: ${{ steps.source.outputs.tag }} + tag: ${{ github.event.workflow_run.head_branch }} overwrite: 'false' github-token: ${{ github.token }} diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index dfbf2d3dc..2d5da1cd4 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # never inferred is a compatibility outcome from the run's conclusion. - name: Verify the producer run and fetch its reports id: source - uses: abicheck/abicheck/actions/verify-source-run@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/verify-source-run@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -98,7 +98,7 @@ jobs: # the other. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/report@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index f3dc6605f..7b528df9b 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -263,7 +263,7 @@ jobs: # pull_request this is the merge commit, NOT the PR head. project-ref: ${{ github.sha }} profile: ${{ env.ABICHECK_PROFILE }} - abicheck-ref: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + abicheck-ref: 0b50f807c8ea05719e414e78564c31ef32a2ea4e - name: Record analysis context id: abi-context @@ -294,7 +294,7 @@ jobs: "pr_base_sha": os.environ.get("PR_BASE_SHA") or None, "pr_base_ref": os.environ.get("PR_BASE_REF") or None, "profile": os.environ["ABICHECK_PROFILE"], - "abicheck_ref": "bc2ee0cc76ec44ef989043f82c34c2b50b2555bb", + "abicheck_ref": "0b50f807c8ea05719e414e78564c31ef32a2ea4e", } with open(os.environ["OUT"], "w") as f: json.dump(doc, f, indent=2, sort_keys=True) @@ -338,10 +338,9 @@ jobs: # resolving the accepted-main baseline. No write scope, no secrets. actions: read env: - ABICHECK_REF: bc2ee0cc76ec44ef989043f82c34c2b50b2555bb REPORT_DIR: ${{ github.workspace }}/abicheck-reports - # Outside REPORT_DIR on purpose -- `abicheck aggregate` scans that - # directory, and a manifest inside it is picked up as an extra target. + # Outside REPORT_DIR on purpose; actions/aggregate refuses a manifest + # inside the directory it globs, rather than aggregating it as a target. # Not runner.temp: the `runner` context is not available in a job's # env block, and referencing it there makes the whole workflow file # invalid (the run fails instantly with no jobs). @@ -370,10 +369,13 @@ jobs: run: | echo "::warning::No ABI snapshots were captured for profile $ABICHECK_PROFILE; every check will aggregate as unavailable." - # ── Baseline staging ──────────────────────────────────────────────── + # ── Baseline staging ───────────────────────────────────────────── # Two distinct questions, two distinct channels, never merged: # accepted-main what did THIS pull request introduce? # release-contract what changed since the selected supported release? + # + # Eligibility is abicheck's (verify-baseline-source); fetching the bytes + # is ours, because only we know where we published them. - name: Stage the release-contract baseline if: ${{ steps.candidate.outcome == 'success' }} @@ -395,73 +397,63 @@ jobs: gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ --pattern "$asset" --dir "$BASELINE_RELEASE_DIR" - - name: Stage the accepted-main baseline + # Which run may supply this pull request's base baseline. Repository, + # workflow, event, branch, head SHA and conclusion are all checked, and + # every rejection is printed -- so "no eligible baseline" can say what it + # did see. not_found and lookup_failed stay distinct outcomes. + - name: Verify the accepted-main baseline source if: ${{ steps.candidate.outcome == 'success' && github.event_name == 'pull_request' }} + id: base-source + continue-on-error: true + uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + with: + mode: producer-run + workflow: .github/workflows/ci-scripts-build.yml + expect-event: push + expect-head-sha: ${{ github.event.pull_request.base.sha }} + expect-head-branch: ${{ github.event.pull_request.base.ref }} + + - name: Stage the accepted-main baseline + if: ${{ steps.base-source.outputs.eligible == 'true' }} id: baseline-main continue-on-error: true env: GH_TOKEN: ${{ github.token }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - BASE_REF: ${{ github.event.pull_request.base.ref }} + RUN_ID: ${{ steps.base-source.outputs.run-id }} run: | - set -euo pipefail - # A baseline is eligible only if ALL of these hold. Selecting by - # exact base SHA alone is not enough: a push run at that SHA can - # come from any branch and can have failed, and the capture step - # deliberately still runs after a test failure -- so without these - # filters a failed run's snapshot could become the baseline this - # pull request is measured against. - # - # * produced by this repository's own ci-scripts-build.yml - # * triggered by a push to the pull request's own base branch - # * head_sha exactly the commit this pull request is based on - # * concluded success - # - # "No eligible run" and "the lookup itself failed" are different - # outcomes and are reported differently. - if ! runs=$(gh api -X GET \ - "repos/$GITHUB_REPOSITORY/actions/workflows/ci-scripts-build.yml/runs" \ - -f head_sha="$BASE_SHA" -f status=completed -f event=push \ - -f branch="$BASE_REF" --paginate); then - echo "::warning::baseline lookup failed for base $BASE_SHA (GitHub API error); reporting an unavailable baseline, not a clean result" - echo "state=lookup-failed" >> "$GITHUB_OUTPUT" - exit 1 - fi - run_id=$(jq -r --arg sha "$BASE_SHA" --arg ref "$BASE_REF" ' - [ .workflow_runs[] - | select(.head_sha == $sha) - | select(.head_branch == $ref) - | select(.conclusion == "success") ] - | sort_by(.run_number) | last | .id // empty' <<<"$runs") - if [ -z "$run_id" ]; then - echo "no successful $BASE_REF run found for base $BASE_SHA" - echo "state=not-found" >> "$GITHUB_OUTPUT" - exit 1 - fi - echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - echo "state=resolved" >> "$GITHUB_OUTPUT" + set -eu mkdir -p "$BASELINE_MAIN_DIR" - if ! gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + if ! gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" \ --name "abicheck-candidate-${ABICHECK_PROFILE}" --dir "$BASELINE_MAIN_DIR"; then - echo "::warning::baseline run $run_id has no abicheck-candidate-$ABICHECK_PROFILE artifact (expired or never produced)" - echo "state=artifact-unavailable" >> "$GITHUB_OUTPUT" + echo "::warning::eligible baseline run $RUN_ID has no abicheck-candidate-$ABICHECK_PROFILE artifact (expired or never produced)" exit 1 fi - # ── Comparison ────────────────────────────────────────────────────── + # ── Comparison ─────────────────────────────────────────────── # One check-target invocation per (component, channel). Every one of # them reads the same two candidate snapshots captured once upstream: # nothing here re-extracts, and nothing re-compares for rendering. - + # + # Resolution runs the full per-target check set -- content digest, + # profile, project_ref, generation, schema and path-escape -- on the + # CANDIDATE side, not only the baseline side. - name: Locate candidate snapshots id: snapshots if: ${{ steps.candidate.outcome == 'success' }} - run: ./.ci-local/abicheck-snapshots.sh "$CANDIDATE_DIR" + uses: abicheck/abicheck/actions/resolve-baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e + with: + kind: members + baseline-path: ${{ env.CANDIDATE_DIR }} + channel: candidate + bundle-members: '["libpvxs", "libpvxsIoc"]' + profile: ${{ env.ABICHECK_PROFILE }} + expected-project-ref: ${{ github.sha }} + expected-baseline-generation: '1' - name: 'libpvxs vs accepted-main' id: main-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -472,7 +464,7 @@ jobs: expected-baseline-generation: '1' requested-depth: headers gate-mode: advisory - new-library: ${{ steps.snapshots.outputs.libpvxs }} + new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} build-config: .ci-local/abicheck.yml head-sha: ${{ github.sha }} base-ref: ${{ github.event.pull_request.base.ref }} @@ -481,7 +473,7 @@ jobs: - name: 'libpvxsIoc vs accepted-main' id: main-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -492,7 +484,7 @@ jobs: expected-baseline-generation: '1' requested-depth: headers gate-mode: advisory - new-library: ${{ steps.snapshots.outputs.libpvxsioc }} + new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} build-config: .ci-local/abicheck.yml head-sha: ${{ github.sha }} base-ref: ${{ github.event.pull_request.base.ref }} @@ -501,7 +493,7 @@ jobs: - name: 'libpvxs vs release-contract' id: rel-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -512,7 +504,7 @@ jobs: requested-depth: headers gate-mode: advisory explicit-id: release - new-library: ${{ steps.snapshots.outputs.libpvxs }} + new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} build-config: .ci-local/abicheck.yml head-sha: ${{ github.sha }} severity-preset: default @@ -520,7 +512,7 @@ jobs: - name: 'libpvxsIoc vs release-contract' id: rel-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@bc2ee0cc76ec44ef989043f82c34c2b50b2555bb + uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -531,79 +523,80 @@ jobs: requested-depth: headers gate-mode: advisory explicit-id: release - new-library: ${{ steps.snapshots.outputs.libpvxsioc }} + new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} build-config: .ci-local/abicheck.yml head-sha: ${{ github.sha }} severity-preset: default - # Every check this event was supposed to produce is named explicitly, - # whether or not it produced a report. A check that did not run is - # declared expected-but-unavailable, so the aggregate says "incomplete" - # rather than "no changes found". - # + # Which checks this event asks for is PVXS policy, so it stays here. # The accepted-main checks exist only on a pull request: there is no PR # base to compare against on a push or tag build, and declaring them # there would manufacture unavailable checks for a question the event - # never asked. - - name: Collect check reports + # never asked. A check that ran but produced nothing is still declared, + # with an empty report, so it aggregates as unavailable rather than + # vanishing from the expected set. + - name: Declare the checks this event asked for + id: declare if: ${{ always() }} + env: + EVENT: ${{ github.event_name }} + MAIN_CORE: ${{ steps.main-core.outputs.report-path }} + MAIN_IOC: ${{ steps.main-ioc.outputs.report-path }} + REL_CORE: ${{ steps.rel-core.outputs.report-path }} + REL_IOC: ${{ steps.rel-ioc.outputs.report-path }} run: | - set -eu - checks="" - if [ "${{ github.event_name }}" = "pull_request" ]; then - checks="$checks ${{ steps.main-core.outputs.check-id || format('libpvxs@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-core.outputs.report-path }}" - checks="$checks ${{ steps.main-ioc.outputs.check-id || format('libpvxsIoc@{0}#accepted-main@headers', env.ABICHECK_PROFILE) }}=${{ steps.main-ioc.outputs.report-path }}" - fi - checks="$checks ${{ steps.rel-core.outputs.check-id || format('libpvxs@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-core.outputs.report-path }}" - checks="$checks ${{ steps.rel-ioc.outputs.check-id || format('libpvxsIoc@{0}#release-contract@headers~release', env.ABICHECK_PROFILE) }}=${{ steps.rel-ioc.outputs.report-path }}" - # shellcheck disable=SC2086 - ./.ci-local/abicheck-collect.sh "$REPORT_DIR" "$EXPECTED_TARGETS" $checks + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os + p = os.environ["ABICHECK_PROFILE"] + checks = [] + if os.environ["EVENT"] == "pull_request": + checks += [ + {"id": f"libpvxs@{p}#accepted-main@headers", + "report": os.environ.get("MAIN_CORE", "")}, + {"id": f"libpvxsIoc@{p}#accepted-main@headers", + "report": os.environ.get("MAIN_IOC", "")}, + ] + checks += [ + {"id": f"libpvxs@{p}#release-contract@headers~release", + "report": os.environ.get("REL_CORE", "")}, + {"id": f"libpvxsIoc@{p}#release-contract@headers~release", + "report": os.environ.get("REL_IOC", "")}, + ] + print("checks=" + json.dumps(checks)) + PY - # One aggregate document over both components and both channels. The - # publisher renders exactly this; it never re-runs a comparison and - # never classifies a verdict of its own. # One aggregate document over every check this event declared. The # publisher renders exactly this; it never re-runs a comparison and # never classifies a verdict of its own. # # This runs even when no candidate was captured: an aggregate over - # entirely unavailable targets is the canonical way to say "the - # analysis did not complete", and it is the same document shape the - # publisher reads on the happy path. There is no second, divergent - # incomplete-state file. + # entirely unavailable targets is the canonical way to say "the analysis + # did not complete", and it is the same document shape the publisher + # reads on the happy path. There is no second, divergent incomplete + # file. + # + # compatibility-exit carries abicheck aggregate's own 0/1/2/4 without + # failing the step, which is what keeps this shadow gate advisory. A + # refused declaration, a usage error, or a document that does not + # describe a real outcome fails the step instead -- operational loss can + # no longer reach the publisher disguised as an empty finding set. - name: Aggregate both components id: aggregate if: ${{ always() }} - run: | - set -euo pipefail - python -m pip install --disable-pip-version-check -q \ - "git+https://github.com/abicheck/abicheck@${ABICHECK_REF}" - - # abicheck aggregate's exit code is a compatibility/coverage - # decision (0 pass, 1 coverage/quality, 2 API break, 4 ABI break), - # and this shadow check's gate is advisory -- so a non-zero code is - # recorded, not swallowed. 64 is a usage error and is ours to fix. - # Run from inside the report directory so each target's recorded - # report_path is a bare filename beside aggregate.json. The - # publisher reads member reports only from the aggregate document's - # own directory and refuses an absolute path, so aggregating with an - # absolute directory argument silently costs every per-target detail. - rc=0 - ( cd "$REPORT_DIR" && abicheck aggregate . \ - --manifest "$EXPECTED_TARGETS" \ - -o "json=aggregate.json" \ - -o "text=aggregate.txt" ) || rc=$? - echo "exit-code=$rc" >> "$GITHUB_OUTPUT" - if [ "$rc" = "64" ]; then - echo "::error::abicheck aggregate rejected its inputs (usage error)" - exit 1 - fi + uses: abicheck/abicheck/actions/aggregate@0b50f807c8ea05719e414e78564c31ef32a2ea4e + with: + reports-dir: ${{ env.REPORT_DIR }} + manifest-path: ${{ env.EXPECTED_TARGETS }} + checks: ${{ steps.declare.outputs.checks }} - # Validate the document we are about to hand downstream, rather - # than checking that one key exists. A file that does not describe - # a real aggregate outcome is an operational failure of this job, - # never an empty finding set. - ./.ci-local/abicheck-validate-aggregate.sh "$REPORT_DIR/aggregate.json" + - name: Summarise the aggregate outcome + if: ${{ always() && steps.aggregate.outcome == 'success' }} + run: | + echo "status=${{ steps.aggregate.outputs.status }}" \ + "coverage=${{ steps.aggregate.outputs.coverage }}" \ + "compatibility-exit=${{ steps.aggregate.outputs.compatibility-exit }}" \ + "analyzed=${{ steps.aggregate.outputs.analyzed }}/${{ steps.aggregate.outputs.expected }}" + echo "channels: ${{ steps.aggregate.outputs.channels }}" - name: Upload ABI reports if: ${{ always() }} diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 5db188400..ccd110023 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -38,9 +38,29 @@ leg's own tests so nothing else that needs the checkout is disturbed, and it runs even if those tests failed, as long as the build produced an installation — an unrelated red test must not hide an ABI finding. -`.ci-local/abicheck-inputs.sh` resolves and validates those inputs and emits -the capture Action's `libraries` declaration. It contains no build -orchestration, no report schema and no gate logic. +`.ci-local/abicheck-components.json` declares the two components by pattern. +Resolving those patterns — picking the real shared object out of its SONAME +alias chain, checking it is an ELF `ET_DYN`, agreeing the target machine +between components, expanding the owned header sets and refusing a stale +exclusion — belongs to abicheck's `actions/baseline` (`library-spec`), not to +PVXS. + +Two assertions the declaration makes deliberately, because a glob alone would +lose them: + +* `include/pvxs/versionNum.h` is named explicitly as well as matched by the + `*.h` glob. A header pattern that matches nothing is a hard error, so a + build that did not generate it fails the capture instead of quietly + producing a 14-header surface in which every `versionNum` declaration reads + as removed. +* `header_exclude: include/pvxs/iochooks.h` is likewise an error if it matches + nothing, so if that header is ever renamed or moved, `libpvxs` cannot + silently re-acquire its sibling's declarations. + +What is left in `.github/actions/abicheck-capture` is PVXS's own build-system +knowledge and nothing else: where `cue.py` recorded `EPICS_BASE`, which +`EPICS_HOST_ARCH` it built for, the native-Linux guard, and rendering those +two values into the declaration. ## Ownership @@ -99,8 +119,18 @@ baseline must additionally come from this repository's own `ci-scripts-build.yml`, from a push to the pull request's base branch, and from a run that **concluded success**. Without the last two, a failed run's snapshot could become the baseline — the capture step deliberately still -runs after a test failure. "No eligible run", "the lookup failed" and "the -artifact expired" are reported as three different outcomes. +runs after a test failure. That eligibility decision is +`actions/verify-baseline-source` (`mode: producer-run`), which also prints +every candidate it rejected and why, so "no eligible baseline" can say what +it did see. "No eligible run", "the lookup failed" and "the artifact +expired" remain three different outcomes; PVXS only fetches the bytes once +the verifier reports `eligible`. + +Tag identity for the release channel is the same Action in `mode: tag`. It +resolves `refs/tags/` explicitly rather than the commits endpoint +(which would happily resolve a branch), peels an annotated tag, assumes no +`v` prefix — PVXS tags are bare versions like `1.5.2` — and requires the tag +to name the exact commit that was built. A missing, expired, wrong-profile or incompatible baseline produces an explicit unavailable/incomplete outcome. It never produces a clean result. @@ -168,9 +198,18 @@ a comparison did not run, the expected checks are still declared and `abicheck aggregate` produces an aggregate document in which those targets are `unavailable`. That is the same shape the publisher reads on the happy path, so a producer failure cannot arrive at the publisher as a file it -does not understand. The document is structurally validated before it is -handed downstream — its status, coverage, gate blocks and target states — -rather than merely checked for the presence of a schema key. +does not understand. `actions/aggregate` validates the document before it is +handed downstream and separates the axes: `compatibility-exit` carries +`abicheck aggregate`'s own 0/1/2/4 without failing the step, which is what +keeps this gate advisory, while a refused declaration, a usage error or a +document that does not describe a real outcome fails the step. Operational +loss cannot reach the publisher disguised as an empty finding set. + +A report that ran and produced garbage is tracked as `unusable`, separately +from one that never ran at all. Both stay declared expected, so one +component's failure never costs the others their diagnostics, and `channels` +splits the roll-up per baseline channel — "the release comparison is +missing" is distinguishable from "one component is missing". Only the checks an event actually has are declared: the accepted-main comparisons exist on a pull request, not on a push or tag build. @@ -183,30 +222,26 @@ prominently reporting a detected break. ABICC remains authoritative. ## Dependency status -Every abicheck Action and the analysis package itself are pinned to one -merged revision, `bc2ee0cc76ec44ef989043f82c34c2b50b2555bb` on abicheck -`main` — the squash of abicheck PR #1311, which added -`actions/verify-source-run` and `actions/report`. Nothing here depends on -an unmerged revision any more. +Every abicheck Action is pinned to one merged, immutable revision: +`0b50f807c8ea05719e414e78564c31ef32a2ea4e` on abicheck `main` — the squash of +abicheck [#1315](https://github.com/abicheck/abicheck/pull/1315), which added +`actions/aggregate`, `actions/verify-baseline-source` and `library-spec` +resolution, on top of [#1311](https://github.com/abicheck/abicheck/pull/1311) +(report-only publication and the aggregate-shaped PR comment). Nothing here +depends on an unmerged revision, a mutable branch, or a placeholder ref. -Two things were checked before moving the pin, not assumed: +The one local composite Action that remains for a generic reason is +`.github/actions/abicheck-publish-baseline`. Upstream's +`publish-baseline.yml` implements a stronger immutability contract but +captures from `build-output.json` artifacts and cannot publish an +already-captured baseline-set; extending it with a pre-captured-set input is +the owed follow-up, tracked upstream. Writing a second publisher here would +be exactly the competing implementation this integration exists to remove. -- `actions/report`, `actions/verify-source-run`, `actions/baseline`, - `actions/check-target` and `actions/stage-baseline` are byte-identical - between the revisions this branch previously pinned and `bc2ee0c`, so the - move changes no Action interface this caller depends on. -- The analysis package is not identical — `bc2ee0c` carries four later - fixes, among them input resolution for sided `--header`/`--include` - values and the demotion of binary churn on exports no public header - declares. Both components were therefore re-captured at `--depth headers` - on `bc2ee0c` and re-aggregated: schema `1.11`, `status: pass`, - `coverage: complete`, 2/2 targets `analyzed`, and the PR comment renders - from that aggregate. - -## Known issues (abicheck product bugs, re-measured 2026-09-16 on `bc2ee0c`) +## Known issues (abicheck product bugs, re-measured 2026-09-16 on `0b50f80`) Re-measured on this branch with abicheck -`bc2ee0cc76ec44ef989043f82c34c2b50b2555bb` and CastXML 0.7.0, by comparing +`0b50f807c8ea05719e414e78564c31ef32a2ea4e` and CastXML 0.7.0, by comparing each component's snapshot against itself — a byte-identical pair, where the only correct answer is "no change". From 7143f5d86ec3893e2f7df0c137fddca2174eb7cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 18:47:58 +0000 Subject: [PATCH 31/44] docs: record the first real-runner validation of the migrated Actions PVXS EPICS run 35130816212 at 22ca9d5, success. This is the execution abicheck's HANDOFF.md said was missing: its Actions' composite step wiring had never run on a GitHub runner, only as unit-tested decisions. All four ran. library-spec resolved the same 15/1 header split and the same two artifacts the deleted script produced, from the same SONAME alias chain -- lib/linux-x86_64 holds libpvxs.so -> libpvxs.so.1.5, and the glob de-duplicated to the one real object instead of analysing the alias twice. resolve-baseline kind: members returned outcome=resolved for both, with libpvxsIoc keeping its real casing. The eligibility check justified itself on this run rather than in theory. A push run for the PR's exact base commit, on the right branch, from the right workflow, did exist -- and had failed. verify-baseline-source refused it by name and printed why. Without that rule a failed run's snapshot would have become the baseline this PR is measured against, which is exactly the case selecting on base SHA alone cannot catch. The aggregate reported status=fail, coverage=empty, 0/4 analyzed, compatibility-exit=1, channels split per baseline channel -- and the step still succeeded. That is the advisory contract: the code is carried, not swallowed, and not converted into a job failure. Timings recorded separately per step so the bounded timeout has evidence behind it: capture 360s dominates, everything else is under 30s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- documentation/abicheck.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/documentation/abicheck.md b/documentation/abicheck.md index ccd110023..ba0217737 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -238,6 +238,47 @@ already-captured baseline-set; extending it with a pre-captured-set input is the owed follow-up, tracked upstream. Writing a second publisher here would be exactly the competing implementation this integration exists to remove. +## Validated on a real runner + +First execution of the migrated Actions: `PVXS EPICS` run +[35130816212](https://github.com/napetrov/pvxs/actions/runs/35130816212) at +`22ca9d5`, conclusion success. + +| Step | Result | Duration | +|---|---|---| +| Resolve EPICS build context | `EPICS_BASE`/arch resolved, declaration rendered | 0.07 s | +| `library-spec` resolution | `libpvxs` 15 headers, `libpvxsIoc` 1 header, 4 include roots each | 0.24 s | +| Capture (both components) | libpvxs 120.9 MB → 2.06 MB zstd; libpvxsIoc 2.35 MB → 94.5 KB | 360 s | +| `resolve-baseline` `kind: members` | `outcome=resolved`, 2 members, correct `libpvxsIoc` casing | 28.6 s | +| `verify-baseline-source` | `not_found`, with the rejection printed | 0.86 s | +| `aggregate` collect / run / validate | 4 declared, 0 collected, 4 missing, 0 unusable | 0.23 / 0.69 / 0.23 s | + +The capture resolved the same 15/1 header split from the *same* SONAME alias +chain the old script filtered by hand: `lib/linux-x86_64/` holds +`libpvxs.so` → `libpvxs.so.1.5`, and the `libpvxs.so*` glob de-duplicated to +the one real object rather than analysing the alias twice. + +The eligibility check earned its place on this very run. A `push` run for +the PR's exact base commit on the right branch, from the right workflow, +did exist — and it had **failed** (an unrelated timing-sensitive test). +`verify-baseline-source` refused it and said why: + +``` +rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'; allowed: success +no eligible producer run was found. This is a real lifecycle state ..., not an error -- +and not a clean comparison either. +``` + +Without that rule a failed run's snapshot would have become the baseline +this pull request was measured against. Selecting on base SHA alone is not +enough, and this is the case that shows it. + +The aggregate then reported `status=fail`, `coverage=empty`, `0/4` +analyzed, `compatibility-exit=1`, with `channels` splitting accepted-main +0/2 and release-contract 0/2 — **and the step still succeeded**, which is +the advisory contract working: a compatibility/coverage code is carried, +not swallowed and not turned into a job failure. + ## Known issues (abicheck product bugs, re-measured 2026-09-16 on `0b50f80`) Re-measured on this branch with abicheck From b13f69d5cb59a8ce06a45a64ec25aea0cd28a53b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 18:59:08 +0000 Subject: [PATCH 32/44] ci: fix four defects the review found in the migrated integration All four verified against the code before changing anything; all four are in files this PR owns. 1. The component declaration was resolved after `cd "$TOP"`. In the matrix leg TOP is the workspace so it worked, but the historical bootstrap points TOP at a checkout of an OLD tag -- a revision that predates .ci-local/abicheck-components.json entirely. The bootstrap would have read a missing file, or a stale declaration if one ever existed there. The declaration belongs to the checkout, so it now resolves against the workspace, with an explicit existence check. This was introduced by the migration itself. 2. The bootstrap gave one job `contents: write` AND ran the historical revision's own code in it. cue.py, its submodules and its makefiles run in the shared workspace, and the local publishing action is loaded from that same workspace afterwards and handed github.token -- so historical code could replace the action that receives the token. `persist-credentials: false` does not cover this: it stops the historical checkout getting credentials, not a later step loading a tampered local action. Split into a `contents: read` build+capture job that hands the baseline-set over as an artifact, and a `contents: write` publishing job that checks out only the default branch and runs none of that code. Asserted mechanically: the write-scoped job's definition contains neither "historical" nor "cue.py". This is also what the task asked for -- read-only build/capture separated from write-capable publication -- and the single-job version did not meet it. 3. post-on was `changes`, so a run whose comparison came back clean published nothing and left an earlier run's warning standing. The contract is one sticky comment per PR/profile that reflects the current state, so a clean resolution has to clear a stale warning. Now `always`. Checked rather than assumed: an INCOMPLETE analysis already counts as a change (total_changes=5 on the real aggregate), so `changes` was publishing that correctly -- it is specifically the clean case it skipped. 4. `gh release list --limit 1` defaults both exclusions to false, so a draft or pre-release could be selected as the release contract. Now --exclude-drafts --exclude-pre-releases. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/actions/abicheck-capture/action.yml | 18 ++++++- .github/workflows/abicheck-baseline.yml | 56 +++++++++++++++++++-- .github/workflows/abicheck-report.yml | 8 ++- .github/workflows/ci-scripts-build.yml | 6 ++- documentation/abicheck.md | 31 ++++++++---- 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index dd65c20c3..6a8787d2b 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -77,9 +77,25 @@ runs: env: TOP: ${{ inputs.top || github.workspace }} SPEC_IN: ${{ inputs.component-spec }} + WORKSPACE: ${{ github.workspace }} SPEC_OUT: ${{ runner.temp }}/abicheck-components.resolved.json run: | set -eu + + # The declaration belongs to the CHECKOUT, not to the tree being + # captured. The historical bootstrap points `top` at an old + # revision that predates this file entirely, so resolving a + # relative spec path after `cd "$TOP"` would read a file that is + # missing (or, worse, a stale declaration) from that revision. + case "$SPEC_IN" in + /*) spec_in=$SPEC_IN ;; + *) spec_in=$WORKSPACE/$SPEC_IN ;; + esac + [ -f "$spec_in" ] || { + echo "::error::component declaration $spec_in does not exist" + exit 64 + } + cd "$TOP" # configure/RELEASE.local is where cue.py records the prepared @@ -111,7 +127,7 @@ runs: # other '$' the declaration might legitimately contain. sed -e "s|\${EPICS_BASE}|$EPICS_BASE|g" \ -e "s|\${EPICS_HOST_ARCH}|$EPICS_HOST_ARCH|g" \ - "$SPEC_IN" > "$SPEC_OUT" + "$spec_in" > "$SPEC_OUT" if grep -q '\${' "$SPEC_OUT"; then echo "::error::unsubstituted placeholder left in $SPEC_OUT" grep -n '\${' "$SPEC_OUT" >&2 diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 774c32a81..769beff64 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -125,16 +125,32 @@ jobs: github-token: ${{ github.token }} # ── Bootstrap: build a historical release once, here ────────────────── - bootstrap: + # ── Bootstrap: build a historical release once, here ────────────────── + # + # Split in two on purpose. The build job runs the requested revision's + # own code (cue.py, its submodules, its makefiles) and therefore holds + # NO write scope: `contents: read` only. It hands the captured + # baseline-set to the publishing job as an artifact. + # + # The publishing job holds `contents: write` but executes none of that + # code -- it checks out only the default branch and consumes the + # artifact. Without the split, historical code could overwrite + # .github/actions/abicheck-publish-baseline in the shared workspace + # before the runner loads it, and that local action is handed + # github.token. + bootstrap-build: if: ${{ github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 60 permissions: - contents: write + contents: read + outputs: + sha: ${{ steps.tag.outputs.sha }} steps: - # The trusted tooling (capture action, helper scripts) comes from the - # default branch; the revision being captured is checked out - # separately. The historical tree's own workflow is never run. + # The trusted tooling (capture action, component declaration) comes + # from the default branch; the revision being captured is checked out + # separately, under historical/. The historical tree's own workflow is + # never run. - uses: actions/checkout@v6 with: persist-credentials: false @@ -174,6 +190,10 @@ jobs: python .ci/cue.py exec python .ci-local/libevent.py python .ci/cue.py build + # Captures with the same declaration and the same shared action the + # matrix leg uses, so the bootstrap cannot drift from normal capture. + # The declaration is read from THIS checkout, not from the historical + # revision, which predates it. - name: Capture the historical revision id: capture uses: ./.github/actions/abicheck-capture @@ -184,6 +204,32 @@ jobs: profile: ${{ env.ABICHECK_PROFILE }} abicheck-ref: ${{ env.ABICHECK_REF }} + - name: Hand the baseline-set to the publishing job + uses: actions/upload-artifact@v7 + with: + name: abicheck-bootstrap-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/baseline-set + if-no-files-found: error + retention-days: 1 + + bootstrap-publish: + needs: bootstrap-build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + # Default branch only. No historical checkout, no historical code, so + # the local action loaded below is the reviewed one. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/download-artifact@v7 + with: + name: abicheck-bootstrap-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/baseline-set + - name: Publish the baseline-set uses: ./.github/actions/abicheck-publish-baseline with: diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 2d5da1cd4..0a7a20ec0 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -113,7 +113,13 @@ jobs: # to prevent. source-run-id: ${{ github.event.workflow_run.id }} source-run-attempt: ${{ github.event.workflow_run.run_attempt }} - post-on: changes + # `always`, not `changes`: a run whose comparison came back clean + # must still publish, otherwise the sticky comment left by an + # earlier run keeps showing a warning that no longer holds. An + # incomplete analysis already counts as a change, so `changes` + # would publish that -- it is specifically the clean resolution it + # would silently skip. + post-on: always detail: standard run-label: >- run ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 7b528df9b..212f4238c 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -387,7 +387,11 @@ jobs: set -eu mkdir -p "$BASELINE_RELEASE_DIR" asset="abicheck-baseline-${ABICHECK_PROFILE}.tar.zst" - tag=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName \ + # gh defaults both exclusions to false, so --limit 1 alone can + # select a draft or a pre-release. The release contract is a + # published final release. + tag=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 \ + --exclude-drafts --exclude-pre-releases --json tagName \ --jq '.[0].tagName // empty') if [ -z "$tag" ]; then echo "no release to resolve a release-contract baseline from" diff --git a/documentation/abicheck.md b/documentation/abicheck.md index ba0217737..54fff45f6 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -141,17 +141,26 @@ A release that predates this integration has no capture, and re-dispatching the old workflow cannot produce one — the workflow *at that tag* has no capture step. `abicheck-baseline.yml`'s `workflow_dispatch` path therefore builds the requested revision once, in the trusted default-branch workflow, -captures it with the same shared action the matrix leg uses, and publishes -the result. It is a one-time operation per release and never runs on a pull -request. - -Publication checks that the tag really is a tag (`refs/tags/`, -resolving annotated tags), that it points at the revision that was built, -and that the baseline-set's own manifest records the profile it is being -published as and covers both components. An already-published asset is left -alone rather than replaced, because a published baseline is an immutable -reference; replacing one changes the meaning of every comparison already -made against it. +captures it with the same shared action and the same component declaration +the matrix leg uses, and publishes the result. It is a one-time operation +per release and never runs on a pull request. + +That path is **two jobs, deliberately**. The build job runs the requested +revision's own code — its `cue.py`, its submodules, its makefiles — and so +holds `contents: read` and nothing else. It hands the captured baseline-set +to the second job as an artifact. The publishing job holds `contents: +write` but executes none of that code: it checks out only the default +branch and consumes the artifact. + +Without the split, historical code could overwrite +`.github/actions/abicheck-publish-baseline` in the shared workspace before +the runner loads it, and that local action is handed `github.token`. +`persist-credentials: false` does not prevent that — it stops the historical +checkout receiving credentials, not a later step loading a tampered local +action. + +The component declaration is read from the default-branch checkout, not +from the historical tree, which predates the file entirely. ## Publication From d97e677d5287f0bb77ad5062759e0c6a1546fdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 22:57:32 +0000 Subject: [PATCH 33/44] ci: repin to pick up the report fix that made the ordering guard work abicheck #1319 fixes a defect that silently disabled a guarantee this integration claims. At the previous pin, actions/report declared `source-run-id` and `source-run-attempt`, documented them, and its run.sh read INPUT_SOURCE_RUN_ID / INPUT_SOURCE_RUN_ATTEMPT -- but action.yml never forwarded the inputs into the step's env block. The values this caller passes were discarded, and run.sh fell through to its documented default: GITHUB_RUN_ID, the PUBLISHER's own run. That orders the sticky comment by when publication was triggered rather than when the analysis ran, so a late re-run of an older commit carries the larger id and overwrites the newer result. It is the exact inversion 440fa8e wired those inputs to prevent, and it was inert from then until now. Nothing in PVXS was wrong; the values simply never arrived. Verified at both revisions rather than inferred: at 0b50f80 action.yml contains no INPUT_SOURCE_RUN_ID at all while run.sh reads it; at 80cf72a both lines are present. Drift across the eight pinned Actions between the two revisions is exactly this: actions/report/action.yml, +2 lines. Nothing else changed, so this is a two-line behavioural fix and not a re-validation of the whole surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/actions/abicheck-capture/action.yml | 2 +- .../abicheck-publish-baseline/action.yml | 2 +- .github/workflows/abicheck-baseline.yml | 6 ++--- .github/workflows/abicheck-report.yml | 4 ++-- .github/workflows/ci-scripts-build.yml | 18 +++++++-------- documentation/abicheck.md | 23 ++++++++++++++----- 6 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index 6a8787d2b..853046649 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -142,7 +142,7 @@ runs: - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: capture - uses: abicheck/abicheck/actions/baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 with: library-spec: ${{ steps.epics.outputs.spec }} library-root: ${{ inputs.top || github.workspace }} diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml index 25622562a..344705c4b 100644 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -57,7 +57,7 @@ runs: - name: Package the baseline-set id: stage - uses: abicheck/abicheck/actions/stage-baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/stage-baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 with: baseline-path: ${{ inputs.baseline-path }} asset-name-template: 'abicheck-baseline-{profile}.tar.zst' diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 769beff64..93f3529ea 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -48,7 +48,7 @@ permissions: env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - ABICHECK_REF: 0b50f807c8ea05719e414e78564c31ef32a2ea4e + ABICHECK_REF: 80cf72abb5856eb623a6056fb35d06a66ad26774 SETUP_PATH: .ci-local CMP: gcc BCFG: default @@ -82,7 +82,7 @@ jobs: # annotated tag rather than treating the tag object as the commit. - name: Verify the producer run id: producer - uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 with: mode: producer-run workflow: .github/workflows/ci-scripts-build.yml @@ -93,7 +93,7 @@ jobs: - name: Verify the tag identity id: tag if: ${{ steps.producer.outputs.eligible == 'true' }} - uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 with: mode: tag tag: ${{ github.event.workflow_run.head_branch }} diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 0a7a20ec0..667d2ab39 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # never inferred is a compatibility outcome from the run's conclusion. - name: Verify the producer run and fetch its reports id: source - uses: abicheck/abicheck/actions/verify-source-run@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/verify-source-run@80cf72abb5856eb623a6056fb35d06a66ad26774 with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -98,7 +98,7 @@ jobs: # the other. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/report@80cf72abb5856eb623a6056fb35d06a66ad26774 with: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 212f4238c..c6dcca8e2 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -263,7 +263,7 @@ jobs: # pull_request this is the merge commit, NOT the PR head. project-ref: ${{ github.sha }} profile: ${{ env.ABICHECK_PROFILE }} - abicheck-ref: 0b50f807c8ea05719e414e78564c31ef32a2ea4e + abicheck-ref: 80cf72abb5856eb623a6056fb35d06a66ad26774 - name: Record analysis context id: abi-context @@ -294,7 +294,7 @@ jobs: "pr_base_sha": os.environ.get("PR_BASE_SHA") or None, "pr_base_ref": os.environ.get("PR_BASE_REF") or None, "profile": os.environ["ABICHECK_PROFILE"], - "abicheck_ref": "0b50f807c8ea05719e414e78564c31ef32a2ea4e", + "abicheck_ref": "80cf72abb5856eb623a6056fb35d06a66ad26774", } with open(os.environ["OUT"], "w") as f: json.dump(doc, f, indent=2, sort_keys=True) @@ -409,7 +409,7 @@ jobs: if: ${{ steps.candidate.outcome == 'success' && github.event_name == 'pull_request' }} id: base-source continue-on-error: true - uses: abicheck/abicheck/actions/verify-baseline-source@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 with: mode: producer-run workflow: .github/workflows/ci-scripts-build.yml @@ -444,7 +444,7 @@ jobs: - name: Locate candidate snapshots id: snapshots if: ${{ steps.candidate.outcome == 'success' }} - uses: abicheck/abicheck/actions/resolve-baseline@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/resolve-baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 with: kind: members baseline-path: ${{ env.CANDIDATE_DIR }} @@ -457,7 +457,7 @@ jobs: - name: 'libpvxs vs accepted-main' id: main-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -477,7 +477,7 @@ jobs: - name: 'libpvxsIoc vs accepted-main' id: main-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -497,7 +497,7 @@ jobs: - name: 'libpvxs vs release-contract' id: rel-core if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -516,7 +516,7 @@ jobs: - name: 'libpvxsIoc vs release-contract' id: rel-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -587,7 +587,7 @@ jobs: - name: Aggregate both components id: aggregate if: ${{ always() }} - uses: abicheck/abicheck/actions/aggregate@0b50f807c8ea05719e414e78564c31ef32a2ea4e + uses: abicheck/abicheck/actions/aggregate@80cf72abb5856eb623a6056fb35d06a66ad26774 with: reports-dir: ${{ env.REPORT_DIR }} manifest-path: ${{ env.EXPECTED_TARGETS }} diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 54fff45f6..3fe271422 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -232,12 +232,23 @@ prominently reporting a detected break. ABICC remains authoritative. ## Dependency status Every abicheck Action is pinned to one merged, immutable revision: -`0b50f807c8ea05719e414e78564c31ef32a2ea4e` on abicheck `main` — the squash of -abicheck [#1315](https://github.com/abicheck/abicheck/pull/1315), which added -`actions/aggregate`, `actions/verify-baseline-source` and `library-spec` -resolution, on top of [#1311](https://github.com/abicheck/abicheck/pull/1311) -(report-only publication and the aggregate-shaped PR comment). Nothing here -depends on an unmerged revision, a mutable branch, or a placeholder ref. +`80cf72abb5856eb623a6056fb35d06a66ad26774` on abicheck `main`. It carries +[#1315](https://github.com/abicheck/abicheck/pull/1315) (`actions/aggregate`, +`actions/verify-baseline-source`, `library-spec` resolution) on top of +[#1311](https://github.com/abicheck/abicheck/pull/1311) (report-only +publication and the aggregate-shaped PR comment), plus +[#1319](https://github.com/abicheck/abicheck/pull/1319). Nothing here depends +on an unmerged revision, a mutable branch, or a placeholder ref. + +**#1319 is why this pin moved, and it fixed a live defect here.** At the +previous pin, `actions/report` declared and documented `source-run-id` / +`source-run-attempt`, and its `run.sh` read `INPUT_SOURCE_RUN_ID` — but +`action.yml` never forwarded the inputs into the step's environment. The +values this workflow passes were therefore discarded, and the ordering guard +silently fell back to `GITHUB_RUN_ID`: the *publisher's* run, which orders by +when publication was triggered rather than when the analysis ran. That is the +precise inversion the guard exists to prevent, and it is what the caller here +was written to avoid. The guard was inert until this pin. The one local composite Action that remains for a generic reason is `.github/actions/abicheck-publish-baseline`. Upstream's From a4965ce55e19c44fd69cd34078e397700e96d3c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 22:58:24 +0000 Subject: [PATCH 34/44] docs: record why the upstream baseline publisher does not fit yet abicheck #1319 added publish-baseline.yml's baseline-set-artifact-prefix -- the pre-captured-set input this integration's local publish action has been waiting for, and stronger than that action: it validates the set against the profile, tag and generation it is being published as. It fits one of the two publication paths. The bootstrap (workflow_dispatch) produces its set in the same run, so the workflow can download it. Tag publication is a workflow_run job whose set lives in the PRODUCING run, and publish-baseline.yml fetches the pre-captured set with actions/download-artifact using `pattern:` alone -- no run-id -- so it can only see artifacts of the run it executes in. Moving the capture into the publishing run would fix the fetch and break the security boundary: the separation between the unprivileged run that captures and the trusted run that publishes is the point. So that is not an option. Adopting it for the bootstrap alone would leave two publishers for one job, which is the duplication this whole change exists to remove. The local action therefore stays, with the gap stated rather than worked around: publish-baseline.yml needs a run-id passthrough on the pre-captured path, or the ability to accept an already-downloaded directory. Either retires the action outright. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- documentation/abicheck.md | 67 ++++++++++++++------------------------- 1 file changed, 24 insertions(+), 43 deletions(-) diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 3fe271422..d8d66629b 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -251,53 +251,34 @@ precise inversion the guard exists to prevent, and it is what the caller here was written to avoid. The guard was inert until this pin. The one local composite Action that remains for a generic reason is -`.github/actions/abicheck-publish-baseline`. Upstream's -`publish-baseline.yml` implements a stronger immutability contract but -captures from `build-output.json` artifacts and cannot publish an -already-captured baseline-set; extending it with a pre-captured-set input is -the owed follow-up, tracked upstream. Writing a second publisher here would -be exactly the competing implementation this integration exists to remove. +`.github/actions/abicheck-publish-baseline`. -## Validated on a real runner +abicheck [#1319](https://github.com/abicheck/abicheck/pull/1319) added the +replacement this was waiting for — `publish-baseline.yml` now takes +`baseline-set-artifact-prefix` and will publish an already-captured +baseline-set, validating it against the profile, release tag and generation +it is being published as. That is strictly stronger than the local action. -First execution of the migrated Actions: `PVXS EPICS` run -[35130816212](https://github.com/napetrov/pvxs/actions/runs/35130816212) at -`22ca9d5`, conclusion success. +It does not yet fit **both** of this integration's publication paths, and +adopting it for one would leave two publishers, which is the duplication +this integration exists to remove: -| Step | Result | Duration | +| Path | Where the set lives | Fits upstream? | |---|---|---| -| Resolve EPICS build context | `EPICS_BASE`/arch resolved, declaration rendered | 0.07 s | -| `library-spec` resolution | `libpvxs` 15 headers, `libpvxsIoc` 1 header, 4 include roots each | 0.24 s | -| Capture (both components) | libpvxs 120.9 MB → 2.06 MB zstd; libpvxsIoc 2.35 MB → 94.5 KB | 360 s | -| `resolve-baseline` `kind: members` | `outcome=resolved`, 2 members, correct `libpvxsIoc` casing | 28.6 s | -| `verify-baseline-source` | `not_found`, with the rejection printed | 0.86 s | -| `aggregate` collect / run / validate | 4 declared, 0 collected, 4 missing, 0 unusable | 0.23 / 0.69 / 0.23 s | - -The capture resolved the same 15/1 header split from the *same* SONAME alias -chain the old script filtered by hand: `lib/linux-x86_64/` holds -`libpvxs.so` → `libpvxs.so.1.5`, and the `libpvxs.so*` glob de-duplicated to -the one real object rather than analysing the alias twice. - -The eligibility check earned its place on this very run. A `push` run for -the PR's exact base commit on the right branch, from the right workflow, -did exist — and it had **failed** (an unrelated timing-sensitive test). -`verify-baseline-source` refused it and said why: - -``` -rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'; allowed: success -no eligible producer run was found. This is a real lifecycle state ..., not an error -- -and not a clean comparison either. -``` - -Without that rule a failed run's snapshot would have become the baseline -this pull request was measured against. Selecting on base SHA alone is not -enough, and this is the case that shows it. - -The aggregate then reported `status=fail`, `coverage=empty`, `0/4` -analyzed, `compatibility-exit=1`, with `channels` splitting accepted-main -0/2 and release-contract 0/2 — **and the step still succeeded**, which is -the advisory contract working: a compatibility/coverage code is carried, -not swallowed and not turned into a job failure. +| Bootstrap (`workflow_dispatch`) | artifact in the *same* run, from `bootstrap-build` | **yes** | +| Tag publication (`workflow_run`) | artifact in the *producing* run | **no** | + +The blocker is one input. `publish-baseline.yml` downloads the set with +`actions/download-artifact` using `pattern:` alone, with no `run-id`, so it +can only see artifacts of the run it is executing in. A `workflow_run` +publisher is by construction a *different* run from the one that captured +the set — that separation is the security boundary, not an accident, so +moving the capture into the publishing run is not an option. + +What would close it: a `run-id` (and token) passthrough on the pre-captured +path, or the ability to hand the workflow an already-downloaded directory. +Either would let both paths use one publisher and retire this action. That +is the remaining upstream dependency; it is not worked around here. ## Known issues (abicheck product bugs, re-measured 2026-09-16 on `0b50f80`) From ea4f75805e1a6ae87703fef80cc866c68b42cc0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 23:06:13 +0000 Subject: [PATCH 35/44] ci: enforce the default-branch restriction the bootstrap only claimed The write-scoped bootstrap-publish job carried the comment "Default branch only" and did not enforce it. workflow_dispatch runs from whichever ref is selected, and actions/checkout with no `ref:` takes that ref -- so dispatching from any branch would load ./.github/actions/abicheck-publish-baseline from that branch into a job holding contents: write and github.token. That is the same class of exposure the previous commit split the jobs to close, reached by a different route: not historical code overwriting the action, but the dispatch ref selecting which copy of it runs. Splitting the jobs removed the first route and left this one. Worse than a missing guard, the comment asserted the property: a reviewer reading "Default branch only" has no reason to check whether the YAML says so. It did not. Now enforced twice. The job refuses to run unless github.ref is the default branch, and its checkout names that branch explicitly rather than inheriting it. bootstrap-build stays unrestricted and read-only on purpose: dispatching it from a branch to exercise the capture is safe, and the publish job simply does not run. The documentation said "checks out only the default branch" too, which was the same unbacked claim; it now describes the enforcement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/workflows/abicheck-baseline.yml | 11 +++++++++-- documentation/abicheck.md | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 93f3529ea..73c8dd9b0 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -214,15 +214,22 @@ jobs: bootstrap-publish: needs: bootstrap-build + # workflow_dispatch runs from whatever ref was selected, and a checkout + # with no `ref:` takes that ref -- so without this guard the local + # publishing action below would be loaded from the dispatched branch + # into a job holding contents: write and github.token. Enforced twice + # on purpose: refuse to run off the default branch at all, and name the + # branch explicitly in the checkout rather than inheriting it. + if: >- + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - # Default branch only. No historical checkout, no historical code, so - # the local action loaded below is the reviewed one. - uses: actions/checkout@v6 with: + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - uses: actions/download-artifact@v7 diff --git a/documentation/abicheck.md b/documentation/abicheck.md index d8d66629b..bc7341748 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -149,8 +149,20 @@ That path is **two jobs, deliberately**. The build job runs the requested revision's own code — its `cue.py`, its submodules, its makefiles — and so holds `contents: read` and nothing else. It hands the captured baseline-set to the second job as an artifact. The publishing job holds `contents: -write` but executes none of that code: it checks out only the default -branch and consumes the artifact. +write` but executes none of that code: it consumes the artifact and +nothing else. + +"Checks out the default branch" is enforced, not assumed. `workflow_dispatch` +runs from whichever ref was selected, and a checkout with no `ref:` takes +*that* ref — so the publishing job both refuses to run unless `github.ref` +is the default branch, and names the default branch explicitly in its +checkout. Without that, dispatching from any branch would load +`./.github/actions/abicheck-publish-baseline` from that branch into the job +holding `contents: write` and `github.token`. + +The build job is deliberately left unrestricted: it is read-only, so +dispatching it from a branch to exercise the capture is safe, and the +publish job simply does not run. Without the split, historical code could overwrite `.github/actions/abicheck-publish-baseline` in the shared workspace before From e27eb5f5f0eb3b28ccbca34cfaf415f229a1388d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:11:11 +0000 Subject: [PATCH 36/44] docs: state that no real baseline comparison has run on this branch The advisory job concludes success on every run of this branch while the aggregate reports `status=fail coverage=empty, 0/4 target(s) analyzed`. That combination is correct -- the gate is advisory and missing coverage is reported as missing rather than as a clean result -- but nothing in the documentation said it was the current state, so a reviewer seeing a green check had no way to know that no ABI comparison has actually run here. Both channels are unavailable for reasons this pull request cannot fix: the base branch's own ci-scripts-build.yml run concluded failure, which verify-baseline-source refuses by name, and no baseline release asset has been published yet. What the real runners do demonstrate is the candidate half -- build reuse, declarative component resolution, capture, and resolve-baseline returning outcome=resolved with two members -- plus the eligibility and aggregation machinery behaving correctly with nothing to compare against. The comparison half has only been exercised against locally constructed snapshot pairs. Separating those two claims is the point of this section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- documentation/abicheck.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/documentation/abicheck.md b/documentation/abicheck.md index bc7341748..391f748cb 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -241,6 +241,38 @@ Advisory (`gate-mode: advisory`) during shadow adoption. Gate status and compatibility are reported separately: the check can be green while prominently reporting a detected break. ABICC remains authoritative. +## What this branch's CI has actually analysed so far + +Nothing, and that is worth stating plainly rather than leaving a reviewer to +infer it from a green check. + +On every run of this branch to date the aggregate reports +`status=fail coverage=empty, 0/4 target(s) analyzed`, with +`channels: {accepted-main: {analyzed: 0, unavailable: 2}, +release-contract: {analyzed: 0, unavailable: 2}}`. The job still concludes +success, because the gate is advisory and operational loss is reported as +loss rather than as a clean result — that part is working as designed. + +Both channels are unavailable for reasons outside this pull request: + +| Channel | Why unavailable | What would change it | +|---|---|---| +| accepted-main | The base branch's own `ci-scripts-build.yml` run concluded `failure`, so `verify-baseline-source` refuses it by name: `rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'; allowed: success` | One green `ci-scripts-build.yml` run on the base branch | +| release-contract | No `abicheck-baseline-.tar.zst` release asset exists yet | The historical bootstrap in `abicheck-baseline.yml`, dispatched on the default branch | + +So what this branch demonstrates on real runners is the *candidate* half — +the build reuse, the declarative component resolution, the capture, and +`resolve-baseline` returning `outcome=resolved` with two members — plus the +eligibility and aggregation machinery behaving correctly when there is +nothing to compare against. The comparison half has been exercised only +against locally constructed snapshot pairs, not against a baseline this +repository published. Do not read a green advisory check here as evidence +that a real ABI comparison ran. + +Neither prerequisite is something this pull request can satisfy on its own, +and neither is worked around: no baseline is fabricated, no comparison is +skipped quietly, and the missing coverage is reported as missing. + ## Dependency status Every abicheck Action is pinned to one merged, immutable revision: From 08b895bfa7e504a2b6dba1f8d5213946cc5db982 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:25:24 +0000 Subject: [PATCH 37/44] ci: verify a baseline-set's revision before publishing it under a tag The publisher checked the manifest's profile and that both components were present, but never that the set describes the commit the tag actually names. A set captured from a different revision could therefore be published as a release's baseline, and every later release-contract comparison would report ABI changes that are really just the gap between two revisions. Nothing downstream catches this. The accepted-main comparisons pass expected-project-ref to check-target, because a pull request knows exactly which commit its baseline must describe. The release-contract comparisons deliberately pass none -- the release is selected at consumption time, so the consumer has no expected ref to assert. That leaves the publisher as the only place either path can catch it. It matters most on the bootstrap path, which runs a historical revision's own build system (cue.py, its submodules, its makefiles) in the shared workspace before the runner loads the capture action from this checkout. Splitting that job in two took the write token out of that code's reach; it did not make the artifact it hands over trustworthy. Verifying the claim where both paths converge is the difference between trusting the producer and checking it. The tag is resolved the same way the rest of this integration resolves tags: refs/tags/ explicitly rather than the commits endpoint, which would resolve a branch of the same name, and an annotated tag is peeled rather than having its tag-object sha accepted as the commit. Verified against a stubbed API: a matching revision passes, a wrong revision is refused by name, an unpeeled annotated-tag sha is refused, and a manifest with no project_ref is refused. Reported by CodeRabbit on PR napetrov/pvxs#2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .../abicheck-publish-baseline/action.yml | 50 ++++++++++++++++++- documentation/abicheck.md | 25 ++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml index 344705c4b..4887620a2 100644 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ b/.github/actions/abicheck-publish-baseline/action.yml @@ -1,7 +1,8 @@ name: 'PVXS ABI baseline publication' description: >- Package a baseline-set and attach it to a release, after checking that - the set really describes the profile it is being published as. + the set really describes the revision and the profile it is being + published as. Shared by abicheck-baseline.yml's automatic and bootstrap paths so the eligibility rules exist once. @@ -55,6 +56,53 @@ runs: exit 1 fi + # Nothing downstream re-checks this. The accepted-main comparisons + # pass `expected-project-ref` to check-target, because a pull request + # knows exactly which commit its baseline must describe. The + # release-contract comparisons cannot: the release is selected at + # consumption time, so they deliberately pass no expected ref. That + # makes this the only point at which a set claiming the wrong revision + # can be caught, for either publishing path. + # + # It matters most on the bootstrap path. That job builds a historical + # revision by running ITS code (cue.py, its submodules, its makefiles) + # in the shared workspace, before the runner loads the capture action + # from this checkout -- so that code can influence what is captured and + # what the manifest records. Splitting the jobs took the write token + # out of its reach; it did not make the artifact trustworthy. Verify + # the claim here, where both paths converge, rather than trusting the + # producer. + - name: Check the set describes the revision it is published as + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + BASELINE_PATH: ${{ inputs.baseline-path }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + manifest="$BASELINE_PATH/manifest.json" + actual=$(jq -r '.project_ref // empty' "$manifest") + [ -n "$actual" ] || { + echo "::error::baseline-set records no project_ref; refusing to publish it as $TAG" + exit 1 + } + + # Resolve refs/tags/ explicitly rather than via the commits + # endpoint, which would happily resolve a branch of the same name, + # and peel an annotated tag instead of treating the tag object's + # own sha as the commit. + ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") + want=$(jq -r '.object.sha' <<<"$ref") + if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then + want=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$want" --jq '.object.sha') + fi + + if [ "$actual" != "$want" ]; then + echo "::error::baseline-set describes $actual but $TAG is $want; refusing to publish it" + exit 1 + fi + echo "baseline-set describes $actual, which is what $TAG names" + - name: Package the baseline-set id: stage uses: abicheck/abicheck/actions/stage-baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 391f748cb..ab0b969ae 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -135,6 +135,31 @@ to name the exact commit that was built. A missing, expired, wrong-profile or incompatible baseline produces an explicit unavailable/incomplete outcome. It never produces a clean result. +### What the baseline publisher refuses + +`.github/actions/abicheck-publish-baseline` validates a set before any of +it is staged or uploaded. It checks the manifest's `profile`, that both +components are present, and that the manifest's `project_ref` is the commit +the requested tag actually names — resolving `refs/tags/` explicitly +rather than through the commits endpoint (which would resolve a branch of +the same name) and peeling an annotated tag rather than accepting the tag +object's own sha. + +The revision check exists because nothing downstream repeats it. The +accepted-main comparisons pass `expected-project-ref` to `check-target`, +since a pull request knows exactly which commit its baseline must describe. +The release-contract comparisons cannot, because the release is chosen at +consumption time — so they deliberately pass no expected ref. The publisher +is therefore the only point where a set claiming the wrong revision can be +caught, on either publishing path. + +That matters most for the bootstrap below, which runs a historical +revision's own build system in the shared workspace before the runner loads +the capture action. Splitting the jobs put the write token out of that +code's reach; it did not make the artifact it produces trustworthy. The +claim is verified where both paths converge rather than trusted from the +producer. + ### Bootstrapping a historical release A release that predates this integration has no capture, and re-dispatching From 5a129fa2bee5c6e9c82c8c06a724460dc26ae84e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 04:03:59 +0000 Subject: [PATCH 38/44] ci: bind the spec as JSON, verify the analysed commit, and say what ran Three defects the completion review identified, each verified against the real code before changing it. 1. The component declaration was rendered with `sed`, which is not JSON escaping. Measured against the real declaration with EPICS_BASE values a path may legitimately contain: `&` silently produced a WRONG value (in a sed replacement it means "the whole match"), `"` produced INVALID JSON, a backslash was dropped, and the `|` delimiter failed the command outright. EPICS_BASE comes from configure/RELEASE.local, so its content is not ours to constrain. The binding is now done by a JSON reader and writer that substitutes exactly two names and refuses any other ${...}, so it stays a two-variable binding rather than a local template engine. All five previously-broken values now bind correctly. The extraction config is now resolved against the trusted checkout explicitly too. It was correct only by accident: a `uses:` step runs with cwd = workspace, so a relative path happened to land in the right tree. Accident is not a property. 2. The publisher displayed a commit the analysis never looked at. verify-source-run's `tested-sha` input was never passed, and empty means "the run's own head SHA" -- for a pull_request producer that is the PR HEAD, not the ephemeral merge commit that was actually built and captured. The comment above that step asserted the opposite, which is the same failure as the "Default branch only" comment fixed earlier: the claim was in prose, not in the code. Now the documented two-pass shape. The producer records the commit it analysed into the REPORTS artifact -- previously this context went only into the candidate artifact, which the publisher never downloads, so it could not have been used even in principle. The publisher reads that claim, validates it as a full hex SHA before it becomes a step output (a bare echo of file contents lets a newline forge any other output of a privileged job), and passes it back through verify-source-run, which accepts it only as the PR head or a merge of it. It remains a claim until the API agrees. Verified: a valid SHA is accepted, and forgery, abbreviated, non-hex, empty and command-substitution inputs are refused. 3. A green advisory job said nothing about having compared nothing. The job summary now leads with the canonical counts -- "ABI/API comparison not performed: 0/4 checks completed. Baselines unavailable; no compatibility verdict was produced." -- and then renders the aggregate through the same upstream renderer the publisher uses, in dry-run mode: no API, no token, no posting, so the analysis job stays contents: read. Every value comes from actions/aggregate's outputs; no report file is re-read and no reason string is parsed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/actions/abicheck-capture/action.yml | 84 +++++++++++++++++---- .github/workflows/abicheck-report.yml | 70 +++++++++++++++-- .github/workflows/ci-scripts-build.yml | 75 ++++++++++++++++++ 3 files changed, 209 insertions(+), 20 deletions(-) diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index 853046649..43ddc5110 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -77,6 +77,7 @@ runs: env: TOP: ${{ inputs.top || github.workspace }} SPEC_IN: ${{ inputs.component-spec }} + CFG_IN: ${{ inputs.build-config }} WORKSPACE: ${{ github.workspace }} SPEC_OUT: ${{ runner.temp }}/abicheck-components.resolved.json run: | @@ -96,6 +97,20 @@ runs: exit 64 } + # Same reasoning for the extraction config. It resolved correctly + # only by accident: a `uses:` step runs with cwd = workspace, so a + # relative path happened to land in the trusted checkout. Make the + # trusted origin explicit rather than depending on the runner's cwd. + case "$CFG_IN" in + "") cfg_in= ;; + /*) cfg_in=$CFG_IN ;; + *) cfg_in=$WORKSPACE/$CFG_IN ;; + esac + if [ -n "$cfg_in" ] && [ ! -f "$cfg_in" ]; then + echo "::error::extraction config $cfg_in does not exist" + exit 64 + fi + cd "$TOP" # configure/RELEASE.local is where cue.py records the prepared @@ -121,22 +136,65 @@ runs: ;; esac - # Render the two build-system values into the declaration. Exactly - # these two placeholders are substituted -- not envsubst, which is - # not guaranteed on every runner image and would also expand any - # other '$' the declaration might legitimately contain. - sed -e "s|\${EPICS_BASE}|$EPICS_BASE|g" \ - -e "s|\${EPICS_HOST_ARCH}|$EPICS_HOST_ARCH|g" \ - "$spec_in" > "$SPEC_OUT" - if grep -q '\${' "$SPEC_OUT"; then - echo "::error::unsubstituted placeholder left in $SPEC_OUT" - grep -n '\${' "$SPEC_OUT" >&2 - exit 64 - fi + # Bind the two build-system values into the declaration. + # + # This is a JSON document, so the binding is done by a JSON reader + # and writer. Textual substitution is not JSON escaping: measured + # against the real declaration, `sed` mangles a value containing + # `&` (which means "the whole match" in a replacement), emits + # invalid JSON for one containing `"`, drops a backslash, and fails + # outright on one containing the `|` delimiter. EPICS_BASE is a + # path from configure/RELEASE.local, so it is not ours to constrain. + # + # Exactly two names are bound and any other ${...} is refused, so + # this stays a two-variable binding rather than growing into a + # local templating language. + SPEC_IN_ABS=$spec_in EPICS_BASE=$EPICS_BASE \ + EPICS_HOST_ARCH=$EPICS_HOST_ARCH SPEC_OUT=$SPEC_OUT \ + python3 - <<'PY' + import json, os, re, sys + + bind = { + "EPICS_BASE": os.environ["EPICS_BASE"], + "EPICS_HOST_ARCH": os.environ["EPICS_HOST_ARCH"], + } + placeholder = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + def sub(text): + def one(m): + name = m.group(1) + if name not in bind: + sys.exit( + "::error::%s is not a value this capture binds; " + "known: %s" % (name, ", ".join(sorted(bind))) + ) + return bind[name] + return placeholder.sub(one, text) + + def walk(node): + if isinstance(node, str): + return sub(node) + if isinstance(node, list): + return [walk(v) for v in node] + if isinstance(node, dict): + return {walk(k): walk(v) for k, v in node.items()} + return node + + with open(os.environ["SPEC_IN_ABS"]) as f: + try: + spec = json.load(f) + except json.JSONDecodeError as exc: + sys.exit("::error::%s is not valid JSON: %s" + % (os.environ["SPEC_IN_ABS"], exc)) + + with open(os.environ["SPEC_OUT"], "w") as f: + json.dump(walk(spec), f, indent=2, sort_keys=True) + PY echo "epics-base=$EPICS_BASE" >> "$GITHUB_OUTPUT" echo "host-arch=$EPICS_HOST_ARCH" >> "$GITHUB_OUTPUT" echo "spec=$SPEC_OUT" >> "$GITHUB_OUTPUT" + echo "build-config=$cfg_in" >> "$GITHUB_OUTPUT" echo "resolved component declaration:" cat "$SPEC_OUT" @@ -150,7 +208,7 @@ runs: project-ref: ${{ inputs.project-ref }} profile: ${{ inputs.profile }} depth: headers - build-config: ${{ inputs.build-config }} + build-config: ${{ steps.epics.outputs.build-config }} baseline-generation: ${{ inputs.baseline-generation }} generator-action-ref: ${{ inputs.abicheck-ref }} # A libpvxs snapshot is ~124 MB of JSON at this depth (measured). diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 667d2ab39..2b8dcc6d4 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -91,11 +91,67 @@ jobs: echo "::error::refusing to publish: ${{ steps.source.outputs.refusal-code }}" exit 1 - # `sha` is the commit that was ACTUALLY analysed. For a pull_request - # producer that is the ephemeral merge commit, not the pull request - # head -- verify-source-run returns them as separate coordinates and - # has already verified their association. Do not substitute one for - # the other. + # The first pass verified the RUN and handed back its artifact, but with + # no `tested-sha` input it reported the run's own head SHA -- on a + # pull_request run that is the pull request HEAD, not the ephemeral + # merge commit the analysis actually built and captured. Publishing + # that would name a commit this analysis never looked at. + # + # So read the producer's claim, then verify it. The file comes out of + # an artifact an unprivileged (possibly fork) job produced, so it is + # validated as a full hex SHA before it becomes a step output: a bare + # `echo "sha=$(cat file)"` would let a newline in that file forge any + # other output of this privileged job. + - name: Read the analysed commit the producer claims + id: tested + run: | + set -euo pipefail + f="${{ steps.source.outputs.artifact-path }}/tested-sha.txt" + if [ ! -f "$f" ]; then + echo "::notice::no tested-sha.txt in the reports artifact; the publisher will fall back to the run head SHA" + exit 0 + fi + sha="$(head -c 100 "$f" | tr -d '[:space:]')" + case "$sha" in + *[!0-9a-fA-F]*|'') echo "::error::tested-sha.txt is not a commit SHA"; exit 1 ;; + esac + [ ${#sha} -eq 40 ] || [ ${#sha} -eq 64 ] || { + echo "::error::tested-sha.txt must hold a full SHA, got ${#sha} characters"; exit 1; } + printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT" + + # Establish that the claimed commit really belongs to this pull request + # -- verify-source-run accepts it only as the PR head or a merge of it, + # and returns `unverified-tested-sha` / `unassociated-tested-sha` + # otherwise. Passing the claim straight to `report` would display a SHA + # nothing had checked. + - name: Verify the analysed commit belongs to this pull request + id: tested-verified + if: ${{ steps.tested.outputs.sha != '' }} + uses: abicheck/abicheck/actions/verify-source-run@80cf72abb5856eb623a6056fb35d06a66ad26774 + with: + source-run-id: ${{ github.event.workflow_run.id }} + expect-repository: ${{ github.repository }} + expect-workflow: .github/workflows/ci-scripts-build.yml + expect-event: pull_request + expect-run-attempt: ${{ github.event.workflow_run.run_attempt }} + allowed-conclusions: '' + artifact-name: abicheck-reports-${{ env.ABICHECK_PROFILE }} + destination: ${{ runner.temp }}/abicheck-reports-verified + tested-sha: ${{ steps.tested.outputs.sha }} + github-token: ${{ github.token }} + + - name: Refuse a commit that does not belong to this pull request + if: ${{ steps.tested.outputs.sha != '' && steps.tested-verified.outputs.verified != 'true' }} + run: | + echo "::error::refusing to publish: ${{ steps.tested-verified.outputs.refusal-code }}" + exit 1 + + # `sha` is the commit that was ACTUALLY analysed: the verified merge + # commit when the producer recorded one, and otherwise the run head SHA + # that the first pass verified. Either way it is a value + # verify-source-run has tied to this pull request -- never the raw + # producer claim, and never the PR head substituted for the merge + # commit. - name: Publish the ABI/API report id: publish uses: abicheck/abicheck/actions/report@80cf72abb5856eb623a6056fb35d06a66ad26774 @@ -103,7 +159,7 @@ jobs: report: ${{ steps.source.outputs.artifact-path }}/aggregate.json repository: ${{ github.repository }} pr-number: ${{ steps.source.outputs.pr-number }} - sha: ${{ steps.source.outputs.tested-sha }} + sha: ${{ steps.tested-verified.outputs.tested-sha || steps.source.outputs.tested-sha }} profile: ${{ env.ABICHECK_PROFILE }} # Order the sticky comment by the PRODUCER's run, not this # publisher's. Left unset these default to the publisher's own run @@ -138,7 +194,7 @@ jobs: set -eu echo "posted=${{ steps.publish.outputs.posted }}" echo "skipped-reason=${{ steps.publish.outputs.skipped-reason }}" - echo "pr=${{ steps.source.outputs.pr-number }} tested-sha=${{ steps.source.outputs.tested-sha }} pr-head=${{ steps.source.outputs.pr-head-sha }} from-fork=${{ steps.source.outputs.from-fork }}" + echo "pr=${{ steps.source.outputs.pr-number }} tested-sha=${{ steps.tested-verified.outputs.tested-sha || steps.source.outputs.tested-sha }} run-head=${{ steps.source.outputs.tested-sha }} pr-head=${{ steps.source.outputs.pr-head-sha }} from-fork=${{ steps.source.outputs.from-fork }}" if [ "${{ steps.publish.outcome }}" != "success" ]; then echo "::error::the ABI/API report could not be published (the analysis result itself is unaffected)" exit 1 diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index c6dcca8e2..ed0a195d6 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -602,6 +602,81 @@ jobs: "analyzed=${{ steps.aggregate.outputs.analyzed }}/${{ steps.aggregate.outputs.expected }}" echo "channels: ${{ steps.aggregate.outputs.channels }}" + # The headline, above the rendered body, so the distinction between + # "compared, and compatible" and "compared nothing" survives a glance at + # a green advisory job. + # + # Every value here is a canonical output of actions/aggregate -- status, + # coverage, analyzed/expected and the per-channel roll-up. Nothing is + # re-derived from report files and no reason string is parsed: the + # reasons stay where the aggregate document and the renderer put them. + - name: State plainly whether a comparison happened + if: ${{ always() && steps.aggregate.outcome == 'success' }} + env: + STATUS: ${{ steps.aggregate.outputs.status }} + COVERAGE: ${{ steps.aggregate.outputs.coverage }} + ANALYZED: ${{ steps.aggregate.outputs.analyzed }} + EXPECTED: ${{ steps.aggregate.outputs.expected }} + CHANNELS: ${{ steps.aggregate.outputs.channels }} + run: | + set -eu + { + if [ "$ANALYZED" -eq 0 ]; then + echo "## ABI/API comparison not performed: $ANALYZED/$EXPECTED checks completed." + echo + echo "Baselines unavailable; no compatibility verdict was produced." + elif [ "$ANALYZED" -lt "$EXPECTED" ]; then + echo "## ABI/API comparison incomplete: $ANALYZED/$EXPECTED checks completed." + echo + echo "Some baselines were unavailable; the verdict below covers only the checks that ran." + else + echo "## ABI/API comparison completed: $ANALYZED/$EXPECTED checks." + fi + echo + echo "\`status=$STATUS\` \`coverage=$COVERAGE\` \`channels=$CHANNELS\`" + echo + } >> "$GITHUB_STEP_SUMMARY" + + # The same renderer the trusted publisher uses, in `dry-run` mode: it + # contacts no API, needs no token and posts nothing, so it stays inside + # this job's `contents: read`. It renders the aggregate that was + # already written -- it does not extract, compare, or re-derive a + # verdict. + # + # This exists so the difference between "compared, and compatible" and + # "compared nothing at all" is visible on the run itself, not only to + # whoever opens aggregate.json. A green advisory job whose summary is + # silent is how an empty aggregate gets mistaken for a clean result. + - name: Render the result into the job summary + if: ${{ always() && steps.aggregate.outcome == 'success' }} + uses: abicheck/abicheck/actions/report@80cf72abb5856eb623a6056fb35d06a66ad26774 + with: + report: ${{ env.REPORT_DIR }}/aggregate.json + repository: ${{ github.repository }} + sha: ${{ github.sha }} + profile: ${{ env.ABICHECK_PROFILE }} + detail: standard + dry-run: 'true' + job-summary: 'true' + + # Provenance for the trusted publisher, written into the REPORTS + # artifact -- the one the publisher actually downloads. It used to be + # written into the candidate artifact, which the publisher never fetches, + # so it could not have been used even in principle. + # + # `github.sha` here is the commit this analysis really ran against: on a + # pull_request run that is the ephemeral merge commit, which is also + # what the capture recorded as its project-ref. It is a CLAIM until the + # publisher verifies it -- verify-source-run checks it is the PR head or + # a merge of it, and refuses otherwise. Nothing downstream trusts this + # file; it only lets the publisher ask the API the right question. + - name: Record the analysed commit for the publisher + if: ${{ always() }} + run: | + set -eu + mkdir -p "$REPORT_DIR" + printf '%s\n' "$GITHUB_SHA" > "$REPORT_DIR/tested-sha.txt" + - name: Upload ABI reports if: ${{ always() }} uses: actions/upload-artifact@v7 From f5ade4a3ddb6c1833798ff44c4528ce9c3c4cca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 04:33:00 +0000 Subject: [PATCH 39/44] ci: retest extraction on the CI toolchain, and actually compare something Two things this branch had been asserting without having measured them. **The C++17 extraction override.** The recorded reason for it was wrong. It blamed CastXML 0.7.0 / Clang 20 and quoted one libstdc++ error for both C++11 and C++14 -- but that was a local experiment on a toolchain CI does not use. Re-measured on the toolchain CI actually selects (CastXML 0.6.20260105-g9864b1e, bundled Clang 21.1.8, GCC 13.3.0), by running `abicheck dump` over the real declared header set once per mode: C++11 and C++14 both still fail, for two DIFFERENT reasons, and C++17 is the only mode that extracts. So the override stays -- but the limitation recorded beside it is now the one that was demonstrated, on the toolchain that matters. Worth noting for anyone who repeats this: a bare `castxml` invocation parses these headers in C++11 quite happily. It is the extraction pipeline that does not. Testing the parser instead of the pipeline is how the previous measurement reached a confident wrong answer. **Four real comparisons.** Until now nothing had compared anything: every CI run reports 0/4, for reasons outside this pull request, and this branch had no evidence the comparison path works at all. All three revisions were built and captured locally with one declaration and one toolchain -- base b5024df, release tag 1.5.2, and head -- and all four checks run. The two channels disagree, which is the result worth having. Against its own base this pull request is COMPATIBLE on both components with zero public additions, removals or modifications, which is what a CI-and-docs-only diff should produce. Against release 1.5.2, libpvxs is BREAKING: two weak typeinfo symbols for a lambda inside SharedPV::Impl::connectSub are in 1.5.2 and gone afterwards. Confirmed independently of abicheck with `nm -D` -- present in 1.5.2, absent from both base and head -- so it is drift between the release and master, not something this pull request did. That distinction is the entire reason the channels are separate, and it is now demonstrated on real data rather than asserted. Controls: an independent rebuild of the same revision is COMPATIBLE at 100% binary compatibility; a disposable public break is caught as func_removed / breaking / artifact_proven; a disposable compatible addition is caught as func_added / compatible / non-gating; snapshots still compare in 1s after their source and install trees are deleted. The controls lived in a throwaway worktree and HEAD is untouched by them. The rebuild control also puts a number on the noise: comparing a revision against an independent rebuild of ITSELF still reports 339 risk changes. That is the floor of the known upstream attribution defect, and it is why the 339-517 risk counts elsewhere are reported but never gated. **A wrong answer, recorded rather than quietly fixed.** The first attempt reported libpvxsIoc BREAKING against its own base -- impossible for this diff. The shared checkout's installed include/ tree (a gitignored build output) still held an earlier session's disposable controls while its source and binary were clean, so the candidate snapshot came from a stale mutated install. The tool was right about the inputs it was given; the inputs were wrong. Everything above was re-measured from fresh worktrees. Also re-checked the upstream publisher gap at abicheck main (db3b12c): actions/ and publish-baseline.yml are byte-identical to this pin, so the missing run-id passthrough is unchanged and there is nothing newer to adopt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .ci-local/abicheck.yml | 27 ++++++-- documentation/abicheck.md | 140 ++++++++++++++++++++++++++++++-------- 2 files changed, 134 insertions(+), 33 deletions(-) diff --git a/.ci-local/abicheck.yml b/.ci-local/abicheck.yml index b91449ac5..605f402e9 100644 --- a/.ci-local/abicheck.yml +++ b/.ci-local/abicheck.yml @@ -7,11 +7,28 @@ compile: options: # PVXS's supported consumer language mode is C++11, and that is what its # own build uses. Parsing the public headers in C++11 or C++14 against - # the runner's libstdc++ 13 is not possible with the supported CastXML - # (0.7.0, Clang 20): libstdc++ 13's fails with - # "statement not allowed in constexpr function" in both modes. Measured - # on 2026-09-16; a conda-forge GCC 16 libstdc++ is worse (it needs C++20 - # to parse its own ). + # libstdc++ 13 is not possible through abicheck's extraction pipeline. + # + # Re-measured 2026-09-17 on the toolchain CI actually selects -- CastXML + # 0.6.20260105-g9864b1e with bundled Clang 21.1.8 (the pin from + # abicheck's own action/install-castxml.sh), GCC 13.3.0, libstdc++ 13 -- + # by running `abicheck dump` over this component's real declared header + # set, once per mode: + # + # -std=c++11 FAILS /usr/include/c++/13/bits/char_traits.h:125:7: + # constexpr function's return type 'void' is not a + # literal type + # -std=c++14 FAILS /usr/include/c++/13/bits/new_allocator.h:147:31: + # call to '__builtin_operator_new' selects non-usual + # allocation function + # -std=c++17 OK 120,702,081 bytes of snapshot + # + # An earlier version of this comment blamed CastXML 0.7.0 / Clang 20 and + # quoted one error for both modes. That was measured in a local + # experiment on a toolchain CI does not use, and it was wrong twice over: + # the limitation is not specific to that CastXML, and C++11 and C++14 + # fail for two different reasons. The limitation is real on the + # toolchain that matters; the recorded reason for it was not. # # This is therefore a deliberate, disclosed deviation of the extraction # context from the consumer language mode, not a silent one, and it is diff --git a/documentation/abicheck.md b/documentation/abicheck.md index ab0b969ae..b8413bef5 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -266,37 +266,117 @@ Advisory (`gate-mode: advisory`) during shadow adoption. Gate status and compatibility are reported separately: the check can be green while prominently reporting a detected break. ABICC remains authoritative. -## What this branch's CI has actually analysed so far +## What has actually been compared -Nothing, and that is worth stating plainly rather than leaving a reviewer to -infer it from a green check. +Two different questions, kept apart on purpose: what this branch's **CI** has +analysed, and what has been **measured locally** on real builds. -On every run of this branch to date the aggregate reports -`status=fail coverage=empty, 0/4 target(s) analyzed`, with -`channels: {accepted-main: {analyzed: 0, unavailable: 2}, -release-contract: {analyzed: 0, unavailable: 2}}`. The job still concludes -success, because the gate is advisory and operational loss is reported as -loss rather than as a clean result — that part is working as designed. +### In CI: nothing yet, and the green advisory check does not say otherwise -Both channels are unavailable for reasons outside this pull request: +Every run of this branch reports `status=fail coverage=empty, +0/4 target(s) analyzed`, `channels: {accepted-main: {analyzed: 0, +unavailable: 2}, release-contract: {analyzed: 0, unavailable: 2}}`. The job +concludes success because the gate is advisory and operational loss is +reported as loss. Since this branch also renders that state into the job +summary, a reader no longer has to open `aggregate.json` to discover it: + +``` +ABI/API comparison not performed: 0/4 checks completed. +Baselines unavailable; no compatibility verdict was produced. +``` + +Both channels are unavailable for reasons this pull request cannot fix, and +one of them is structural rather than transient: | Channel | Why unavailable | What would change it | |---|---|---| -| accepted-main | The base branch's own `ci-scripts-build.yml` run concluded `failure`, so `verify-baseline-source` refuses it by name: `rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'; allowed: success` | One green `ci-scripts-build.yml` run on the base branch | -| release-contract | No `abicheck-baseline-.tar.zst` release asset exists yet | The historical bootstrap in `abicheck-baseline.yml`, dispatched on the default branch | - -So what this branch demonstrates on real runners is the *candidate* half — -the build reuse, the declarative component resolution, the capture, and -`resolve-baseline` returning `outcome=resolved` with two members — plus the -eligibility and aggregation machinery behaving correctly when there is -nothing to compare against. The comparison half has been exercised only -against locally constructed snapshot pairs, not against a baseline this -repository published. Do not read a green advisory check here as evidence -that a real ABI comparison ran. - -Neither prerequisite is something this pull request can satisfy on its own, -and neither is worked around: no baseline is fabricated, no comparison is -skipped quietly, and the missing coverage is reported as missing. +| accepted-main | The pull request's base commit `b5024df` is on `master`, whose `ci-scripts-build.yml` contains **no abicheck integration at all** — so no push run of that revision ever produced, or could produce, a candidate artifact. `verify-baseline-source` additionally refuses master's one run by name (`rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'`), but fixing that run would not help: the artifact it would need does not exist in that revision's workflow. | This integration reaching the default branch, after which the base commit of the next pull request has a real capture. | +| release-contract | The fork has tags (`1.5.2`, `1.5.1`, …) but **zero GitHub Releases**, so there is no release to carry an asset and nothing for `resolve-baseline` to fetch. | An authorized one-time bootstrap: create the release for the selected tag, then dispatch `abicheck-baseline.yml` for it. | + +Neither is worked around. No baseline is fabricated, no comparison is +quietly skipped, and the missing coverage is reported as missing. + +### Locally: four real comparisons, on real builds + +To show the comparison path actually works — rather than only that the +plumbing is wired — all three revisions were built and captured with the +same declaration and the same pinned toolchain CI selects (CastXML +`0.6.20260105-g9864b1e`, bundled Clang `21.1.8`, GCC 13.3.0, EPICS Base 7.0, +bundled libevent at the identical submodule commit in all three revisions, +so the profile really is constant). + +| Check | Old side | Verdict | Gating | Public +/−/mod | +|---|---|---|---|---| +| libpvxs vs accepted-main | base `b5024df` | **COMPATIBLE** | 0 | 0 / 0 / 0 | +| libpvxsIoc vs accepted-main | base `b5024df` | **COMPATIBLE** | 0 | 0 / 0 / 0 | +| libpvxs vs release-contract | tag `1.5.2` (`8e00eae`) | **BREAKING** | 2 | 1 / 2 / 0 | +| libpvxsIoc vs release-contract | tag `1.5.2` (`8e00eae`) | **COMPATIBLE** | 0 | 0 / 0 / 0 | + +**The two channels disagree, and that is the point.** Against its own base +this pull request introduces nothing — as it should, since it changes no +runtime source. Against release 1.5.2 `libpvxs` is breaking, because two +weak typeinfo symbols for a lambda inside +`SharedPV::Impl::connectSub(...)` are present in 1.5.2 and gone afterwards. +That was confirmed independently of abicheck with `nm -D`: both symbols are +in 1.5.2's binary and absent from **both** the base and the head binary — so +the removal happened somewhere between 1.5.2 and `master`, and is not this +pull request's doing. A release-relative finding is drift since the release, +never automatically a change this pull request introduced. + +### Controls + +| Control | Result | +|---|---| +| Independent rebuild, same revision | COMPATIBLE, 0 gating, 100% binary compatibility | +| Disposable public break (`testAfterShutdown()` removed from the binary) | detected `func_removed`, severity `breaking`, `artifact_proven`, gating 1, exit 4 | +| Disposable compatible addition (`abicheckProbe(long)`) | detected `func_added`, severity `compatible`, non-gating | +| Snapshot independence | comparison succeeded in 1s after the source and install trees were deleted, with no re-extraction | +| Test mutations in shared source | none: the controls lived in a throwaway worktree, and `HEAD` still declares `testAfterShutdown()` and knows nothing of `abicheckProbe` | + +The break control also produced an unplanned `exported_not_public` finding +for the helper the control renamed, which is the tool correctly objecting to +a symbol exported without a public declaration. + +### What the risk-change counts actually mean + +Every comparison above also reports 339–517 non-gating `risk` changes. The +independent-rebuild control puts a number on how much of that is signal: +rebuilding **the same revision** and comparing it against itself still +produces **339** risk changes for `libpvxsIoc`. That is the noise floor of +the known upstream attribution defect (dependency and sibling symbols +attributed to the component), not change. It is why this integration stays +advisory, and why those counts are reported but not gated. + +### Measured separately + +| Stage | Time | +|---|---| +| Build (per revision, EPICS Base and libevent already present) | 46–50 s | +| Capture `libpvxs` (15 headers, ~120 MB snapshot) | 67 s | +| Capture `libpvxsIoc` (1 header, ~2.3 MB snapshot) | 7 s | +| Compare `libpvxs` | 131–133 s | +| Compare `libpvxsIoc` | 2 s | +| Compare from snapshots after the trees were deleted | 1 s | + +The abicheck job's earlier ≈1-minute wall time is not a comparison +benchmark: that job performed **zero** comparisons. Snapshot compression +changes stored bytes, not extraction time. + +### A measurement error worth recording + +The first run of this exercise reported `libpvxsIoc` **BREAKING against its +own base**, which is impossible for a pull request that changes no runtime +source. The cause was not the tool: the shared checkout's *installed* +`include/` tree (a gitignored build output) still held an earlier session's +disposable controls — `testAfterShutdown()` deleted and `abicheckProbe(long)` +added — while its source and binary were clean. The candidate snapshot was +therefore taken from a stale, mutated install tree. + +The finding was real for the inputs given; the inputs were wrong. Every +number above was re-measured from freshly built worktrees, and the stale +install tree has been refreshed. The git source was never affected. This is +the concrete reason capture must be pinned to the build that produced it +rather than to whatever happens to be lying in an install directory. ## Dependency status @@ -337,9 +417,13 @@ this integration exists to remove: | Bootstrap (`workflow_dispatch`) | artifact in the *same* run, from `bootstrap-build` | **yes** | | Tag publication (`workflow_run`) | artifact in the *producing* run | **no** | -The blocker is one input. `publish-baseline.yml` downloads the set with -`actions/download-artifact` using `pattern:` alone, with no `run-id`, so it -can only see artifacts of the run it is executing in. A `workflow_run` +The blocker is one input, and it is unchanged as of abicheck `main` +(`db3b12c`): `publish-baseline.yml` downloads the set with +`actions/download-artifact` using `pattern:`/`name:` alone, with no `run-id` +and no token, so it can only see artifacts of the run it is executing in. +Re-checked at both this pin and `main` — `actions/` and +`publish-baseline.yml` are byte-identical between the two, so there is +nothing newer to adopt and no reason to move the pin for it. A `workflow_run` publisher is by construction a *different* run from the one that captured the set — that separation is the security boundary, not an accident, so moving the capture into the publishing run is not an option. From 40c8eb89a8dd0dffc53135a9d176b59f5f8dcea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 04:51:05 +0000 Subject: [PATCH 40/44] ci: drop a renderer step that cannot load, and record why The abicheck job failed at "Set up job" on the previous commit -- before any step ran -- because the dry-run renderer step I added references abicheck/abicheck/actions/report, and that action fails GitHub Actions template validation at the pinned revision: actions/report/action.yml (Line: 119, Col: 18): Unrecognized named-value: 'github'. Located at position 1 within expression: github.event.workflow_run.id The cause is prose, not logic. Two input DESCRIPTIONS quote `${{ github.event.workflow_run.id }}` and `${{ github.event.workflow_run.run_attempt }}` as usage examples (action.yml:122 and :133). Actions evaluates expressions inside input descriptions and the `github` context does not exist in action metadata. The same syntax is legal in inputs.*.default -- actions/check-target uses `default: ${{ github.repository }}` without trouble -- but not in a description. It is unconditional: validation happens when the file is parsed, so no caller, input set or trigger avoids it. actions/report is the only abicheck Action with an expression in a description, and no abicheck workflow references actions/report at all, which is why it had no in-repo consumer to catch this. The step is removed rather than worked around. The mandated summary survives: the headline is written from actions/aggregate's own outputs and depends on nothing that fails to load. The finding that matters more than my step: abicheck-report.yml cannot work as pinned either, since its publish step loads the same action. That workflow has never run -- it is workflow_run-only and not yet on a default branch -- so nothing had exercised it. The publication path is therefore blocked on upstream twice: a default-branch deployment AND this fix. Not papered over, and not vendored: patching a published Action into this repository is the local reimplementation this integration exists to remove. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFumn6v9CSsM3euZ8ekcqy --- .github/workflows/ci-scripts-build.yml | 43 ++++++++++++++------------ documentation/abicheck.md | 43 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index ed0a195d6..b67dd8ca1 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -637,27 +637,30 @@ jobs: echo } >> "$GITHUB_STEP_SUMMARY" - # The same renderer the trusted publisher uses, in `dry-run` mode: it - # contacts no API, needs no token and posts nothing, so it stays inside - # this job's `contents: read`. It renders the aggregate that was - # already written -- it does not extract, compare, or re-derive a - # verdict. + # A bounded rendering of the aggregate belongs here, and upstream's + # actions/report `dry-run: true` is exactly that -- no API, no token, + # nothing posted. It cannot be used at this pin: the action fails + # TEMPLATE VALIDATION and the job dies in "Set up job", before any step + # runs: # - # This exists so the difference between "compared, and compatible" and - # "compared nothing at all" is visible on the run itself, not only to - # whoever opens aggregate.json. A green advisory job whose summary is - # silent is how an empty aggregate gets mistaken for a clean result. - - name: Render the result into the job summary - if: ${{ always() && steps.aggregate.outcome == 'success' }} - uses: abicheck/abicheck/actions/report@80cf72abb5856eb623a6056fb35d06a66ad26774 - with: - report: ${{ env.REPORT_DIR }}/aggregate.json - repository: ${{ github.repository }} - sha: ${{ github.sha }} - profile: ${{ env.ABICHECK_PROFILE }} - detail: standard - dry-run: 'true' - job-summary: 'true' + # abicheck/abicheck//actions/report/action.yml (Line: 119, + # Col: 18): Unrecognized named-value: 'github'. Located at position 1 + # within expression: github.event.workflow_run.id + # + # The cause is prose, not logic. Two input DESCRIPTIONS quote + # `${{ github.event.workflow_run.id }}` and + # `${{ github.event.workflow_run.run_attempt }}` as usage examples + # (action.yml:122 and :133). Actions evaluates expressions inside input + # descriptions, and the `github` context does not exist in action + # metadata -- so the file cannot be loaded from any workflow, whatever + # inputs a caller passes. Passing the inputs explicitly does not help; + # validation happens when the file is parsed. + # + # It is unconditional and it is upstream's to fix: reported with this + # reproduction. It also means `abicheck-report.yml` cannot work as + # pinned either -- see documentation/abicheck.md. Nothing is + # reimplemented here to route around it; the headline above already + # states the outcome from the aggregate's own outputs. # Provenance for the trusted publisher, written into the REPORTS # artifact -- the one the publisher actually downloads. It used to be diff --git a/documentation/abicheck.md b/documentation/abicheck.md index b8413bef5..604e39bff 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -378,6 +378,49 @@ install tree has been refreshed. The git source was never affected. This is the concrete reason capture must be pinned to the build that produced it rather than to whatever happens to be lying in an install directory. +## Blocking upstream defect: `actions/report` cannot be loaded + +`abicheck/abicheck/actions/report` fails GitHub Actions **template +validation** at the pinned revision, so any job referencing it dies in +`Set up job` before a single step runs: + +``` +abicheck/abicheck//actions/report/action.yml (Line: 119, Col: 18): +Unrecognized named-value: 'github'. Located at position 1 within +expression: github.event.workflow_run.id +``` + +The cause is prose, not logic. Two input **descriptions** quote +`${{ github.event.workflow_run.id }}` and +`${{ github.event.workflow_run.run_attempt }}` as usage examples +(`action.yml:122` and `:133`). Actions evaluates `${{ }}` inside input +descriptions, and the `github` context does not exist in action metadata. +`${{ }}` is legal in `inputs.*.default` and `outputs.*.value` — and +`actions/check-target` uses `default: ${{ github.repository }}` perfectly +happily — but not in a description. + +It is unconditional: no caller, no set of inputs and no trigger avoids it, +because validation happens when the file is parsed. `actions/report` is the +only abicheck Action with an expression in a description, and **no abicheck +workflow references `actions/report`**, which is why it has no in-repo +consumer to have caught it. + +What it costs here, stated plainly: + +| Path | Effect | +|---|---| +| Bounded rendering in the read-only analysis job | removed — the job could not start. The canonical headline above still reports the outcome from `actions/aggregate`'s own outputs. | +| `abicheck-report.yml`, the trusted publisher | **cannot work as pinned.** Its `actions/report` step would fail the same way. The publisher has never run — it is `workflow_run`-only and not yet on a default branch — so this was never observed. | + +So the publication path is blocked on upstream twice over: it needs a +default-branch deployment *and* it needs this defect fixed. Neither is +worked around here; deleting two expression markers from someone else's +documentation is upstream's call, and vendoring a patched copy of a +published Action is exactly the local reimplementation this integration +exists to remove. + +Reported upstream with the reproduction above. + ## Dependency status Every abicheck Action is pinned to one merged, immutable revision: From e4c54289658ee2a954496d4d526402d5f0089f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:34:51 +0000 Subject: [PATCH 41/44] ci: consolidate the abicheck integration onto supported owners Delete the machinery abicheck now owns, and pin every Action to one merged, immutable revision (e38c3f9, abicheck#1325 on main). Deleted .github/actions/abicheck-publish-baseline/action.yml (149 lines). Both publication paths now call abicheck's reusable publish-baseline.yml, which owns packaging, the manifest/schema/profile/generation/digest gate, tag resolution and the immutability chain -- identity-verified idempotent republish and fail-closed on a conflicting asset, rather than filename-only idempotence. The automatic path takes the bytes from the producer run through the API with that run's repository, workflow, event, id, attempt and conclusion verified; the bootstrap hands its set over as an artifact, not a path assumed to survive a job boundary. The publication tag and the captured revision stay separate values (expected-project-ref: commit). The consumer tested-sha.txt writer and parser, the second verify-source-run call and the second artifact download. One acquisition now carries the whole canonical context: actions/aggregate records analysis_context, and verify-source-run reads it back with provenance-from/report-from. Missing context is refused (require-provenance), never reported as the PR head. The inline recursive JSON binding routine in the capture Action, replaced by actions/baseline's library-spec-bindings. The hand-written gh/jq tag peeling in the bootstrap, replaced by verify-baseline-source mode: tag. The Python that rebuilt every target@profile#channel@depth string and reconstructed the expected set. Each check is declared from check-target's own check-id output; which checks an event asks for stays PVXS policy, expressed as the step's `if:`. The handwritten rule that analyzed == 0 means "baselines unavailable", and the duplicate status-echo block. One canonical bounded renderer (aggregate.txt) plus the document's own coverage/status/channels outputs; the cause comes from the structured outcome, because zero analyses can equally mean a failed capture or a corrupt report. Restored post-on: changes -- the pinned revision clears a resolved result in place and posts for a material analysis limitation, so `always` is not needed to clear a stale warning. Preserved: one build and one capture per component reused across both channels; all four PR comparisons (two components x two channels) with accepted-main limited to pull-request events; the C++17 extraction deviation; read-only build and trusted write-capable publication in separate jobs with the default-branch restriction; bounded artifact processing; advisory gate. Documentation reduced to a maintenance guide; investigation history moves to the pull request evidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q2Herd15iLCwK3ainaw52a --- .ci-local/abicheck.yml | 58 +- .github/actions/abicheck-capture/action.yml | 149 +--- .../abicheck-publish-baseline/action.yml | 149 ---- .github/workflows/abicheck-baseline.yml | 253 +++---- .github/workflows/abicheck-report.yml | 208 +++--- .github/workflows/ci-scripts-build.yml | 312 +++----- documentation/abicheck.md | 704 +++++------------- 7 files changed, 546 insertions(+), 1287 deletions(-) delete mode 100644 .github/actions/abicheck-publish-baseline/action.yml diff --git a/.ci-local/abicheck.yml b/.ci-local/abicheck.yml index 605f402e9..775cdd7bd 100644 --- a/.ci-local/abicheck.yml +++ b/.ci-local/abicheck.yml @@ -1,47 +1,25 @@ # Extraction context for the advisory abicheck shadow check. # -# This describes how the PUBLIC HEADERS are parsed for ABI/API extraction. -# It does not change how PVXS itself is built: the libraries analysed are -# exactly the ones `cue.py build` produced, with PVXS's normal flags. +# This describes how the PUBLIC HEADERS are parsed. It does not change how +# PVXS itself is built: the libraries analysed are exactly the ones +# `cue.py build` produced, with PVXS's normal flags. compile: options: - # PVXS's supported consumer language mode is C++11, and that is what its - # own build uses. Parsing the public headers in C++11 or C++14 against - # libstdc++ 13 is not possible through abicheck's extraction pipeline. - # - # Re-measured 2026-09-17 on the toolchain CI actually selects -- CastXML - # 0.6.20260105-g9864b1e with bundled Clang 21.1.8 (the pin from - # abicheck's own action/install-castxml.sh), GCC 13.3.0, libstdc++ 13 -- - # by running `abicheck dump` over this component's real declared header - # set, once per mode: - # - # -std=c++11 FAILS /usr/include/c++/13/bits/char_traits.h:125:7: - # constexpr function's return type 'void' is not a - # literal type - # -std=c++14 FAILS /usr/include/c++/13/bits/new_allocator.h:147:31: - # call to '__builtin_operator_new' selects non-usual - # allocation function - # -std=c++17 OK 120,702,081 bytes of snapshot - # - # An earlier version of this comment blamed CastXML 0.7.0 / Clang 20 and - # quoted one error for both modes. That was measured in a local - # experiment on a toolchain CI does not use, and it was wrong twice over: - # the limitation is not specific to that CastXML, and C++11 and C++14 - # fail for two different reasons. The limitation is real on the - # toolchain that matters; the recorded reason for it was not. - # - # This is therefore a deliberate, disclosed deviation of the extraction - # context from the consumer language mode, not a silent one, and it is - # applied identically to libpvxs and libpvxsIoc so the two components and - # both sides of every comparison are interpreted the same way. Remove it - # as soon as a toolchain that parses the headers in C++11 is available. + # CURRENT LIMITATION. PVXS's supported consumer language mode is C++11, + # but abicheck's extraction pipeline cannot parse these headers against + # libstdc++ 13 in C++11 or C++14 (each fails, for a different reason, on + # the toolchain CI selects). C++17 extraction is therefore a disclosed + # deviation from the consumer language mode -- it is NOT validation of + # C++11 consumer behaviour -- and it is applied identically to both + # components and to both sides of every comparison. Remove it once the + # pipeline parses the headers in C++11. Measurements and the upstream + # issue are linked from the pull request. - -std=c++17 - # - # Deliberately NOT set here: PVXS_API_BUILDING (a library-build-only - # macro) and PVXS_ENABLE_EXPERT_API (an opt-in expert surface). The - # headers are parsed the way an ordinary consumer sees them. + # Deliberately NOT set: PVXS_API_BUILDING (a library-build-only macro) + # and PVXS_ENABLE_EXPERT_API (an opt-in expert surface). The headers are + # parsed the way an ordinary consumer sees them. assurance: - # A comparison that could not complete must be reported as incomplete, not - # as a clean result. The shadow gate stays advisory; this controls what - # the analysis is allowed to claim, not whether CI goes red. + # A comparison that could not complete is reported as incomplete, never as + # a clean result. The shadow gate stays advisory; this controls what the + # analysis may claim, not whether CI goes red. require_complete: true diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index 43ddc5110..d46991199 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -1,27 +1,24 @@ name: 'PVXS ABI capture' description: >- - Capture ABI/API evidence for libpvxs and libpvxsIoc from an installation - that a normal PVXS build has already produced. Performs no build, no - comparison and no reporting. + Capture ABI/API evidence for libpvxs and libpvxsIoc from an installation a + normal PVXS build has already produced. Performs no build, no comparison + and no reporting. What is PVXS-specific lives here: where the EPICS dependency was prepared - and which host architecture it built for. Everything else -- selecting the - real shared object out of its SONAME alias chain, checking it is an ELF - ET_DYN, agreeing the target machine between components, expanding the owned - header sets and refusing a stale exclusion -- belongs to abicheck's own - baseline Action, which reads the component declaration in - .ci-local/abicheck-components.json. + and which host architecture it built for. Selecting the real shared object + out of its SONAME alias chain, ELF/machine agreement, expanding the owned + header sets, refusing a stale exclusion and binding the two build-decided + values into the declaration all belong to abicheck's baseline Action, + which reads .ci-local/abicheck-components.json. - Used by the selected matrix leg of ci-scripts-build.yml (the candidate - capture) and by abicheck-baseline.yml's one-time historical bootstrap, so - the two produce identical baseline-sets from one definition. + Shared by ci-scripts-build.yml's candidate capture and + abicheck-baseline.yml's historical bootstrap, so the two cannot drift. inputs: top: description: > - Root of the built PVXS tree to capture from. Defaults to the workspace; - the historical bootstrap builds into its own directory and points this - at it. + Root of the built PVXS tree to capture from. Defaults to the + workspace; the historical bootstrap points this at its own checkout. required: false default: '' output-dir: @@ -68,9 +65,6 @@ outputs: runs: using: 'composite' steps: - # The only project-specific knowledge left: where cue.py put EPICS Base - # and which host arch it built for. These are build-system facts, not - # ABI facts, so abicheck deliberately hard-codes neither. - name: Resolve EPICS build context id: epics shell: bash @@ -79,36 +73,18 @@ runs: SPEC_IN: ${{ inputs.component-spec }} CFG_IN: ${{ inputs.build-config }} WORKSPACE: ${{ github.workspace }} - SPEC_OUT: ${{ runner.temp }}/abicheck-components.resolved.json run: | set -eu - # The declaration belongs to the CHECKOUT, not to the tree being - # captured. The historical bootstrap points `top` at an old - # revision that predates this file entirely, so resolving a - # relative spec path after `cd "$TOP"` would read a file that is - # missing (or, worse, a stale declaration) from that revision. - case "$SPEC_IN" in - /*) spec_in=$SPEC_IN ;; - *) spec_in=$WORKSPACE/$SPEC_IN ;; - esac - [ -f "$spec_in" ] || { - echo "::error::component declaration $spec_in does not exist" - exit 64 - } - - # Same reasoning for the extraction config. It resolved correctly - # only by accident: a `uses:` step runs with cwd = workspace, so a - # relative path happened to land in the trusted checkout. Make the - # trusted origin explicit rather than depending on the runner's cwd. - case "$CFG_IN" in - "") cfg_in= ;; - /*) cfg_in=$CFG_IN ;; - *) cfg_in=$WORKSPACE/$CFG_IN ;; - esac - if [ -n "$cfg_in" ] && [ ! -f "$cfg_in" ]; then - echo "::error::extraction config $cfg_in does not exist" - exit 64 + # Both trusted inputs belong to the CHECKOUT, not to the tree being + # captured: the bootstrap points `top` at a revision that predates + # them, so a relative path resolved after `cd "$TOP"` would read a + # missing or stale file from that revision. + case "$SPEC_IN" in /*) spec=$SPEC_IN ;; *) spec=$WORKSPACE/$SPEC_IN ;; esac + case "$CFG_IN" in '') cfg= ;; /*) cfg=$CFG_IN ;; *) cfg=$WORKSPACE/$CFG_IN ;; esac + [ -f "$spec" ] || { echo "::error::component declaration $spec does not exist"; exit 64; } + if [ -n "$cfg" ] && [ ! -f "$cfg" ]; then + echo "::error::extraction config $cfg does not exist"; exit 64 fi cd "$TOP" @@ -128,81 +104,32 @@ runs: if [ -z "$EPICS_HOST_ARCH" ] && [ -x "$EPICS_BASE/startup/EpicsHostArch" ]; then EPICS_HOST_ARCH=$("$EPICS_BASE/startup/EpicsHostArch") fi + # This capture is declared for a native Linux install layout only. case "$EPICS_HOST_ARCH" in linux-*) ;; - *) - echo "::error::this capture is declared for a native Linux host arch, got '${EPICS_HOST_ARCH:-}'" - exit 64 - ;; + *) echo "::error::expected a native Linux host arch, got '${EPICS_HOST_ARCH:-}'" + exit 64 ;; esac - # Bind the two build-system values into the declaration. - # - # This is a JSON document, so the binding is done by a JSON reader - # and writer. Textual substitution is not JSON escaping: measured - # against the real declaration, `sed` mangles a value containing - # `&` (which means "the whole match" in a replacement), emits - # invalid JSON for one containing `"`, drops a backslash, and fails - # outright on one containing the `|` delimiter. EPICS_BASE is a - # path from configure/RELEASE.local, so it is not ours to constrain. - # - # Exactly two names are bound and any other ${...} is refused, so - # this stays a two-variable binding rather than growing into a - # local templating language. - SPEC_IN_ABS=$spec_in EPICS_BASE=$EPICS_BASE \ - EPICS_HOST_ARCH=$EPICS_HOST_ARCH SPEC_OUT=$SPEC_OUT \ - python3 - <<'PY' - import json, os, re, sys - - bind = { - "EPICS_BASE": os.environ["EPICS_BASE"], - "EPICS_HOST_ARCH": os.environ["EPICS_HOST_ARCH"], - } - placeholder = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") - - def sub(text): - def one(m): - name = m.group(1) - if name not in bind: - sys.exit( - "::error::%s is not a value this capture binds; " - "known: %s" % (name, ", ".join(sorted(bind))) - ) - return bind[name] - return placeholder.sub(one, text) - - def walk(node): - if isinstance(node, str): - return sub(node) - if isinstance(node, list): - return [walk(v) for v in node] - if isinstance(node, dict): - return {walk(k): walk(v) for k, v in node.items()} - return node - - with open(os.environ["SPEC_IN_ABS"]) as f: - try: - spec = json.load(f) - except json.JSONDecodeError as exc: - sys.exit("::error::%s is not valid JSON: %s" - % (os.environ["SPEC_IN_ABS"], exc)) - - with open(os.environ["SPEC_OUT"], "w") as f: - json.dump(walk(spec), f, indent=2, sort_keys=True) - PY - - echo "epics-base=$EPICS_BASE" >> "$GITHUB_OUTPUT" - echo "host-arch=$EPICS_HOST_ARCH" >> "$GITHUB_OUTPUT" - echo "spec=$SPEC_OUT" >> "$GITHUB_OUTPUT" - echo "build-config=$cfg_in" >> "$GITHUB_OUTPUT" - echo "resolved component declaration:" - cat "$SPEC_OUT" + { + echo "epics-base=$EPICS_BASE" + echo "host-arch=$EPICS_HOST_ARCH" + echo "spec=$spec" + echo "build-config=$cfg" + } >> "$GITHUB_OUTPUT" - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: capture - uses: abicheck/abicheck/actions/baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/baseline@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: library-spec: ${{ steps.epics.outputs.spec }} + # Binding is abicheck's: it walks the document as JSON and inserts + # each value literally, so a path containing `&`, a quote or a + # backslash is not mangled the way a textual pass would. The two + # names below are the whole allowlist; any other ${...} is refused. + library-spec-bindings: | + EPICS_BASE=${{ steps.epics.outputs.epics-base }} + EPICS_HOST_ARCH=${{ steps.epics.outputs.host-arch }} library-root: ${{ inputs.top || github.workspace }} output-dir: ${{ inputs.output-dir }} project-ref: ${{ inputs.project-ref }} diff --git a/.github/actions/abicheck-publish-baseline/action.yml b/.github/actions/abicheck-publish-baseline/action.yml deleted file mode 100644 index 4887620a2..000000000 --- a/.github/actions/abicheck-publish-baseline/action.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: 'PVXS ABI baseline publication' -description: >- - Package a baseline-set and attach it to a release, after checking that - the set really describes the revision and the profile it is being - published as. - - Shared by abicheck-baseline.yml's automatic and bootstrap paths so the - eligibility rules exist once. - -inputs: - baseline-path: - description: 'Baseline-set directory (manifest.json plus snapshots).' - required: true - profile: - description: 'Profile this set is being published as.' - required: true - tag: - description: 'Release tag to attach the asset to.' - required: true - overwrite: - description: > - Replace an existing asset of the same name. A published baseline is - an immutable reference: replacing it silently changes the meaning of - every comparison already made against it. - required: false - default: 'false' - github-token: - description: 'Token with contents: write for the release upload.' - required: true - -runs: - using: 'composite' - steps: - # actions/stage-baseline packages whatever it is given and explicitly - # does not check the manifest's own profile against the name it is - # published under, so a mismatch would produce an asset that - # resolve-baseline later rejects as wrong_profile for every consumer. - # Check it here, before anything is uploaded. - - name: Check the set describes the profile it is published as - shell: bash - env: - BASELINE_PATH: ${{ inputs.baseline-path }} - EXPECTED_PROFILE: ${{ inputs.profile }} - run: | - set -euo pipefail - manifest="$BASELINE_PATH/manifest.json" - test -s "$manifest" || { echo "::error::no manifest.json in $BASELINE_PATH"; exit 1; } - actual=$(jq -r '.profile // empty' "$manifest") - if [ "$actual" != "$EXPECTED_PROFILE" ]; then - echo "::error::baseline-set records profile '$actual' but is being published as '$EXPECTED_PROFILE'" - exit 1 - fi - libs=$(jq -r '[.artifacts[].library] | sort | join(",")' "$manifest") - if [ "$libs" != "libpvxs,libpvxsIoc" ]; then - echo "::error::baseline-set covers '$libs', expected both libpvxs and libpvxsIoc" - exit 1 - fi - - # Nothing downstream re-checks this. The accepted-main comparisons - # pass `expected-project-ref` to check-target, because a pull request - # knows exactly which commit its baseline must describe. The - # release-contract comparisons cannot: the release is selected at - # consumption time, so they deliberately pass no expected ref. That - # makes this the only point at which a set claiming the wrong revision - # can be caught, for either publishing path. - # - # It matters most on the bootstrap path. That job builds a historical - # revision by running ITS code (cue.py, its submodules, its makefiles) - # in the shared workspace, before the runner loads the capture action - # from this checkout -- so that code can influence what is captured and - # what the manifest records. Splitting the jobs took the write token - # out of its reach; it did not make the artifact trustworthy. Verify - # the claim here, where both paths converge, rather than trusting the - # producer. - - name: Check the set describes the revision it is published as - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - BASELINE_PATH: ${{ inputs.baseline-path }} - TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - manifest="$BASELINE_PATH/manifest.json" - actual=$(jq -r '.project_ref // empty' "$manifest") - [ -n "$actual" ] || { - echo "::error::baseline-set records no project_ref; refusing to publish it as $TAG" - exit 1 - } - - # Resolve refs/tags/ explicitly rather than via the commits - # endpoint, which would happily resolve a branch of the same name, - # and peel an annotated tag instead of treating the tag object's - # own sha as the commit. - ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") - want=$(jq -r '.object.sha' <<<"$ref") - if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then - want=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$want" --jq '.object.sha') - fi - - if [ "$actual" != "$want" ]; then - echo "::error::baseline-set describes $actual but $TAG is $want; refusing to publish it" - exit 1 - fi - echo "baseline-set describes $actual, which is what $TAG names" - - - name: Package the baseline-set - id: stage - uses: abicheck/abicheck/actions/stage-baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 - with: - baseline-path: ${{ inputs.baseline-path }} - asset-name-template: 'abicheck-baseline-{profile}.tar.zst' - profile: ${{ inputs.profile }} - - - name: Attach to the release - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - TAG: ${{ inputs.tag }} - ASSET: ${{ steps.stage.outputs.archive-path }} - OVERWRITE: ${{ inputs.overwrite }} - run: | - set -euo pipefail - test -s "$ASSET" - name=$(basename "$ASSET") - - if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::error::no release exists for tag $TAG; create the release before publishing its baseline" - exit 1 - fi - - existing=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ - --json assets --jq "[.assets[].name] | index(\"$name\") // empty") - if [ -n "$existing" ]; then - if [ "$OVERWRITE" != "true" ]; then - # Re-running publication for a release that already has its - # baseline is a no-op, not a silent replacement. - echo "::notice::$name is already published for $TAG; leaving the existing immutable asset in place" - exit 0 - fi - # gh refuses an asset name that already exists unless --clobber - # is given, so the explicit-overwrite path has to pass it. It is - # deliberately confined to this branch: the default path above - # never replaces a published baseline. - echo "::warning::replacing the existing $name for $TAG at explicit request" - gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" --clobber - exit 0 - fi - - gh release upload "$TAG" "$ASSET" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 73c8dd9b0..676498495 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -1,23 +1,21 @@ # Publish the release-contract ABI baseline-set for the selected profile. # -# Two paths, both running only from this repository's default branch: +# Two paths, both running only from this repository's default branch, and +# both publishing through abicheck's own reusable publish-baseline.yml -- +# which owns packaging, the manifest/schema/profile/generation/digest gate, +# tag resolution and the immutability chain (identity-verified idempotent +# republish, fail-closed on a conflicting asset). # -# automatic A tag build of PVXS EPICS already captured the tagged -# revision. This packages that capture and attaches it to the -# release. Nothing is rebuilt. +# automatic A tag build of "PVXS EPICS" already captured the tagged +# revision. Its capture is published from that run. Nothing +# is rebuilt. # -# bootstrap A historical release predates the capture integration, so -# no tag build of it ever produced a baseline-set. Dispatching -# the old workflow cannot help: the workflow AT that tag has -# no capture step. This path therefore builds the requested -# revision once, here, with the current trusted tooling, and -# publishes the result. It is a one-time operation per -# release, never part of a pull request. -# -# Trust boundary: publication happens only for a verified tag of this -# repository, from a successful producer run (automatic) or from a build -# this trusted workflow performed itself (bootstrap). A snapshot produced -# by contributor code can never reach the release-contract channel. +# bootstrap A historical release predates the capture integration, so no +# tag build of it ever produced a baseline-set, and dispatching +# the old workflow cannot help: the workflow AT that tag has no +# capture step. This path builds the requested revision once, +# here, with the current trusted tooling. One-time per release, +# never part of a pull request. # # The accepted-main channel needs nothing here: a pull request resolves it # from the successful default-branch run for its own base commit. @@ -34,145 +32,111 @@ on: description: 'Release tag to build, capture and publish a baseline-set for.' required: true type: string - overwrite: - description: > - Replace an existing asset of the same name. Off by default: a - published baseline is an immutable reference, and silently - replacing it changes the meaning of every past comparison. - required: false - default: false - type: boolean permissions: contents: read env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - ABICHECK_REF: 80cf72abb5856eb623a6056fb35d06a66ad26774 - SETUP_PATH: .ci-local - CMP: gcc - BCFG: default - BASE: "7.0" - SET: defaults + ABICHECK_REF: e38c3f91b8f956f7c8fa51b60d17fc4030969dec jobs: - # ── Automatic: package the tag build's own capture ──────────────────── - publish: + # ── Automatic: publish the tag build's own capture ───────────────────── + # + # A push run of "PVXS EPICS" can be a branch push as easily as a tag push, + # so ask the shared verifier whether the ref really is a tag naming the + # commit that was built, and publish only then. PVXS tags are bare + # versions ("1.5.2"); the verifier assumes no "v" prefix, resolves + # refs/tags/ explicitly rather than through a revision endpoint that + # would resolve a branch, and peels an annotated tag. + tag-gate: if: >- github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 permissions: - actions: read - contents: write + contents: read + outputs: + eligible: ${{ steps.tag.outputs.eligible }} steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - # Two questions, both answered by the shared verifier rather than by - # hand-written gh/jq here: - # 1. may a baseline be taken from this producer run at all? - # 2. is the name it built really a tag, and does that tag name the - # exact commit that was built? - # PVXS tags are bare versions ("1.5.2"), not "v"-prefixed; the verifier - # assumes no prefix and resolves refs/tags/ explicitly, peeling an - # annotated tag rather than treating the tag object as the commit. - - name: Verify the producer run - id: producer - uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 - with: - mode: producer-run - workflow: .github/workflows/ci-scripts-build.yml - expect-event: push - expect-head-sha: ${{ github.event.workflow_run.head_sha }} - expect-head-branch: ${{ github.event.workflow_run.head_branch }} - - - name: Verify the tag identity - id: tag - if: ${{ steps.producer.outputs.eligible == 'true' }} - uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 + - id: tag + uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: mode: tag tag: ${{ github.event.workflow_run.head_branch }} built-sha: ${{ github.event.workflow_run.head_sha }} - - - name: Note why nothing will be published - if: ${{ steps.producer.outputs.eligible != 'true' || steps.tag.outputs.eligible != 'true' }} + - if: ${{ steps.tag.outputs.eligible != 'true' }} + env: + OUTCOME: ${{ steps.tag.outputs.outcome }} run: | - echo "producer outcome: ${{ steps.producer.outputs.outcome || 'not evaluated' }}" - echo "tag outcome: ${{ steps.tag.outputs.outcome || 'not evaluated' }}" - echo "nothing to publish from this run." - - - uses: actions/download-artifact@v7 - if: ${{ steps.tag.outputs.eligible == 'true' }} - with: - name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} - path: ${{ runner.temp }}/baseline-set - run-id: ${{ steps.producer.outputs.run-id }} - github-token: ${{ github.token }} + echo "nothing to publish from this run; tag outcome: $OUTCOME" - - name: Publish the baseline-set - if: ${{ steps.tag.outputs.eligible == 'true' }} - uses: ./.github/actions/abicheck-publish-baseline - with: - baseline-path: ${{ runner.temp }}/baseline-set - profile: ${{ env.ABICHECK_PROFILE }} - tag: ${{ github.event.workflow_run.head_branch }} - overwrite: 'false' - github-token: ${{ github.token }} - - # ── Bootstrap: build a historical release once, here ────────────────── - # ── Bootstrap: build a historical release once, here ────────────────── - # - # Split in two on purpose. The build job runs the requested revision's - # own code (cue.py, its submodules, its makefiles) and therefore holds - # NO write scope: `contents: read` only. It hands the captured - # baseline-set to the publishing job as an artifact. + publish: + needs: tag-gate + if: ${{ needs.tag-gate.outputs.eligible == 'true' }} + permissions: + # actions: read is what takes the artifact bytes through the API from + # the producer run this publication was triggered by. + actions: read + contents: write + uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + with: + # Publish the finished set the producer captured; no dump, no build + # query, no compiler, no comparison happens in this workflow. + baseline-set-artifact-prefix: abicheck-candidate- + # The set comes from a DIFFERENT, already-completed run, so its origin + # is established rather than assumed: repository, workflow identity, + # event, id, attempt and conclusion are each checked, the artifacts are + # then fetched by their own ids, and a pull-request-triggered producer + # is refused unconditionally. + baseline-set-source-run-id: ${{ github.event.workflow_run.id }} + baseline-set-source-repository: ${{ github.repository }} + baseline-set-source-run-attempt: ${{ github.event.workflow_run.run_attempt }} + baseline-set-expect-workflow: .github/workflows/ci-scripts-build.yml + baseline-set-expect-event: push + baseline-set-allowed-conclusions: success + release-tag: ${{ github.event.workflow_run.head_branch }} + # The publication tag and the revision the set records are two + # different identifiers: a producer stamps the commit it built. + # 'commit' resolves the tag through refs/tags/ and requires the + # set's own project_ref to equal that commit -- never the other way + # round, and never a tag-valued rewrite of a SHA-valued manifest. + expected-project-ref: commit + baseline-generation: '1' + asset-name-template: 'abicheck-baseline-{profile}.tar.zst' + + # ── Bootstrap: build a historical release once, here ─────────────────── # - # The publishing job holds `contents: write` but executes none of that - # code -- it checks out only the default branch and consumes the - # artifact. Without the split, historical code could overwrite - # .github/actions/abicheck-publish-baseline in the shared workspace - # before the runner loads it, and that local action is handed - # github.token. + # Split in two on purpose. This job runs the requested revision's own code + # (cue.py, its submodules, its makefiles) and therefore holds no write + # scope. It hands the captured set to the publishing job as an artifact. bootstrap-build: if: ${{ github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read - outputs: - sha: ${{ steps.tag.outputs.sha }} steps: - # The trusted tooling (capture action, component declaration) comes - # from the default branch; the revision being captured is checked out - # separately, under historical/. The historical tree's own workflow is - # never run. + # The trusted tooling (capture action, component declaration) comes from + # this checkout; the revision being captured is checked out separately + # under historical/, and its own workflow is never run. - uses: actions/checkout@v6 with: persist-credentials: false - - name: Verify the requested tag + - name: Resolve the requested tag id: tag - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG") - obj_sha=$(jq -r '.object.sha' <<<"$ref") - if [ "$(jq -r '.object.type' <<<"$ref")" = "tag" ]; then - obj_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$obj_sha" --jq '.object.sha') - fi - echo "sha=$obj_sha" >> "$GITHUB_OUTPUT" + uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + with: + mode: tag + tag: ${{ inputs.tag }} - name: Check out the historical revision uses: actions/checkout@v6 with: - ref: ${{ steps.tag.outputs.sha }} + ref: ${{ steps.tag.outputs.commit-sha }} submodules: true persist-credentials: false path: historical @@ -182,6 +146,7 @@ jobs: sudo apt-get update sudo apt-get -y install libreadline-dev cmake + # PVXS's normal build commands, the same ones the CI matrix runs. - name: Build the historical revision working-directory: historical run: | @@ -190,20 +155,19 @@ jobs: python .ci/cue.py exec python .ci-local/libevent.py python .ci/cue.py build - # Captures with the same declaration and the same shared action the - # matrix leg uses, so the bootstrap cannot drift from normal capture. - # The declaration is read from THIS checkout, not from the historical - # revision, which predates it. + # Same declaration and same shared action as the matrix leg, so the + # bootstrap cannot drift from normal capture. - name: Capture the historical revision - id: capture uses: ./.github/actions/abicheck-capture with: top: ${{ github.workspace }}/historical output-dir: ${{ runner.temp }}/baseline-set - project-ref: ${{ steps.tag.outputs.sha }} + project-ref: ${{ steps.tag.outputs.commit-sha }} profile: ${{ env.ABICHECK_PROFILE }} abicheck-ref: ${{ env.ABICHECK_REF }} + # An artifact, not a path: the set has to cross a real job boundary, and + # a producer-local directory does not exist in the publishing job. - name: Hand the baseline-set to the publishing job uses: actions/upload-artifact@v7 with: @@ -214,34 +178,19 @@ jobs: bootstrap-publish: needs: bootstrap-build - # workflow_dispatch runs from whatever ref was selected, and a checkout - # with no `ref:` takes that ref -- so without this guard the local - # publishing action below would be loaded from the dispatched branch - # into a job holding contents: write and github.token. Enforced twice - # on purpose: refuse to run off the default branch at all, and name the - # branch explicitly in the checkout rather than inheriting it. - if: >- - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - runs-on: ubuntu-latest - timeout-minutes: 15 + # workflow_dispatch runs from whatever ref was selected. Refuse to + # publish off anything but the default branch, so the write-capable half + # can never be a dispatched branch's copy of this file. + if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} permissions: + actions: read contents: write - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - uses: actions/download-artifact@v7 - with: - name: abicheck-bootstrap-${{ env.ABICHECK_PROFILE }} - path: ${{ runner.temp }}/baseline-set - - - name: Publish the baseline-set - uses: ./.github/actions/abicheck-publish-baseline - with: - baseline-path: ${{ runner.temp }}/baseline-set - profile: ${{ env.ABICHECK_PROFILE }} - tag: ${{ inputs.tag }} - overwrite: ${{ inputs.overwrite }} - github-token: ${{ github.token }} + uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + with: + # Same-run handoff: the artifact was uploaded by bootstrap-build, in + # this run, so no source-run verification is needed or possible. + baseline-set-artifact-prefix: abicheck-bootstrap- + release-tag: ${{ inputs.tag }} + expected-project-ref: commit + baseline-generation: '1' + asset-name-template: 'abicheck-baseline-{profile}.tar.zst' diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index 2b8dcc6d4..b68c02234 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -1,31 +1,31 @@ # Trusted publisher for the advisory ABI/API check. # # The analysis runs unprivileged in ci-scripts-build.yml, under the -# contributor's own pull request, including from forks. That job has -# `contents: read` and no secrets, so it cannot comment, and must not be -# given the ability to. This workflow is the other half. -# -# Almost all of the work is done by two reviewed abicheck Actions rather -# than by logic grown here: +# contributor's own pull request, including from forks: that job has +# `contents: read` and no secrets, so it cannot comment and must not be able +# to. This workflow is the other half, and it is two reviewed Actions: # # actions/verify-source-run establishes WHICH run this is, WHICH pull # request it belongs to, and WHAT commit was -# actually analysed -- all from the GitHub -# API, never from the artifact -- and extracts -# the artifact under size/entry/ratio caps. +# actually analysed -- from the GitHub API, +# never from the artifact -- and hands back the +# verified report it extracted under +# size/entry/ratio caps. # actions/report renders the producer's canonical aggregate # document and maintains the sticky comment. -# It runs no analysis, no compiler and no -# build query. +# No analysis, no compiler, no build query. # -# Neither makes the contributor's report contents trusted evidence. Origin -# and assurance are preserved; what is enforced is the recipient and +# Neither makes the contributor's report contents trusted evidence. Origin +# and assurance are preserved; what is enforced is the recipient and the # execution boundary. # -# DEPLOYMENT PREREQUISITE: workflow_run only ever runs the copy of this -# file on the default branch. Until a maintainer has merged it there, -# nothing in a pull request -- including the pull request that adds this -# file -- publishes a comment. That is by design. +# DEPLOYMENT PREREQUISITE: workflow_run only ever runs the copy of this file +# on the default branch. Until a maintainer has merged it there, nothing in +# a pull request -- including the pull request that adds this file -- +# publishes a comment. That is by design. +# +# KNOWN BLOCKER: at the pinned revision actions/report cannot be loaded at +# all; see documentation/abicheck.md ("Current limitations"). name: ABI/API report @@ -39,12 +39,11 @@ permissions: pull-requests: write concurrency: - # One publication at a time per pull request and profile, so two producer - # runs for the same PR cannot interleave their comment updates. Keyed on - # the producer's head branch because the PR number is only known after - # verification. Superseded runs are NOT cancelled: ordering is enforced - # by the publisher's own freshness check against what is already on the - # pull request, which cancellation alone cannot do. + # One publication at a time per pull request and profile. Keyed on the + # producer's head branch because the PR number is only known after + # verification. Superseded runs are NOT cancelled: ordering is enforced by + # the publisher's own producer-run freshness guard, which cancellation + # alone cannot do. group: >- abicheck-report-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}-linux-x86_64-gcc-default-base7.0-bundled-libevent @@ -52,130 +51,90 @@ concurrency: jobs: publish: - # Only a pull-request-triggered producer has a pull request to report - # to. A push/tag run publishes baselines instead (abicheck-baseline.yml). + # Only a pull-request-triggered producer has a pull request to report to. + # A push/tag run publishes baselines instead (abicheck-baseline.yml). if: ${{ github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest timeout-minutes: 10 env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent steps: - # Establishes identity and hands back a verified artifact. Every - # coordinate below comes from the API: the artifact is contributor - # controlled and may claim any repository, pull request or SHA. + # ONE acquisition. The artifact is downloaded and extracted once, the + # producer's recorded analysis context is read out of it by the same + # bounded reader, and the commit it names is verified against the pull + # request through the API before it can reach a step output. # - # `allowed-conclusions` is deliberately empty. A producer run that - # failed for an unrelated reason (a flaky platform test) still has a - # real ABI result to report, and a failed analysis must be published as - # an explicit incomplete state rather than silently withheld. What is - # never inferred is a compatibility outcome from the run's conclusion. - - name: Verify the producer run and fetch its reports + # `allowed-conclusions` is deliberately empty: a producer run that failed + # for an unrelated reason (a flaky platform test) still has a real ABI + # result, and a failed analysis must be published as an explicit + # incomplete state rather than withheld. What is never inferred is a + # compatibility outcome from the run's conclusion. + - name: Verify the producer run and acquire its report id: source - uses: abicheck/abicheck/actions/verify-source-run@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/verify-source-run@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} expect-workflow: .github/workflows/ci-scripts-build.yml expect-event: pull_request - # Bind to the attempt that actually triggered this publication, not - # to whatever attempt the API reports by the time we ask. + # Bind to the attempt that triggered this publication, not to + # whatever attempt the API reports by the time we ask. expect-run-attempt: ${{ github.event.workflow_run.run_attempt }} allowed-conclusions: '' artifact-name: abicheck-reports-${{ env.ABICHECK_PROFILE }} destination: ${{ runner.temp }}/abicheck-reports + # On a pull_request run the analysis builds an ephemeral merge commit + # that no API endpoint names, so only the producer can say what was + # analysed: actions/aggregate records it in the aggregate document's + # own analysis_context block, and this reads it back out. + provenance-from: aggregate.json + # An artifact recording no context is refused rather than falling + # back to the PR head, which names a tree the analysis never saw. + require-provenance: 'true' + # The document to render, returned together with the identity it was + # checked under -- not a path reassembled from artifact-path. + report-from: aggregate.json github-token: ${{ github.token }} - name: Refuse to publish an unverified run if: ${{ steps.source.outputs.verified != 'true' }} + env: + REFUSAL: ${{ steps.source.outputs.refusal-code }} run: | - echo "::error::refusing to publish: ${{ steps.source.outputs.refusal-code }}" + echo "::error::refusing to publish: $REFUSAL" exit 1 - # The first pass verified the RUN and handed back its artifact, but with - # no `tested-sha` input it reported the run's own head SHA -- on a - # pull_request run that is the pull request HEAD, not the ephemeral - # merge commit the analysis actually built and captured. Publishing - # that would name a commit this analysis never looked at. - # - # So read the producer's claim, then verify it. The file comes out of - # an artifact an unprivileged (possibly fork) job produced, so it is - # validated as a full hex SHA before it becomes a step output: a bare - # `echo "sha=$(cat file)"` would let a newline in that file forge any - # other output of this privileged job. - - name: Read the analysed commit the producer claims - id: tested - run: | - set -euo pipefail - f="${{ steps.source.outputs.artifact-path }}/tested-sha.txt" - if [ ! -f "$f" ]; then - echo "::notice::no tested-sha.txt in the reports artifact; the publisher will fall back to the run head SHA" - exit 0 - fi - sha="$(head -c 100 "$f" | tr -d '[:space:]')" - case "$sha" in - *[!0-9a-fA-F]*|'') echo "::error::tested-sha.txt is not a commit SHA"; exit 1 ;; - esac - [ ${#sha} -eq 40 ] || [ ${#sha} -eq 64 ] || { - echo "::error::tested-sha.txt must hold a full SHA, got ${#sha} characters"; exit 1; } - printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT" - - # Establish that the claimed commit really belongs to this pull request - # -- verify-source-run accepts it only as the PR head or a merge of it, - # and returns `unverified-tested-sha` / `unassociated-tested-sha` - # otherwise. Passing the claim straight to `report` would display a SHA - # nothing had checked. - - name: Verify the analysed commit belongs to this pull request - id: tested-verified - if: ${{ steps.tested.outputs.sha != '' }} - uses: abicheck/abicheck/actions/verify-source-run@80cf72abb5856eb623a6056fb35d06a66ad26774 - with: - source-run-id: ${{ github.event.workflow_run.id }} - expect-repository: ${{ github.repository }} - expect-workflow: .github/workflows/ci-scripts-build.yml - expect-event: pull_request - expect-run-attempt: ${{ github.event.workflow_run.run_attempt }} - allowed-conclusions: '' - artifact-name: abicheck-reports-${{ env.ABICHECK_PROFILE }} - destination: ${{ runner.temp }}/abicheck-reports-verified - tested-sha: ${{ steps.tested.outputs.sha }} - github-token: ${{ github.token }} - - - name: Refuse a commit that does not belong to this pull request - if: ${{ steps.tested.outputs.sha != '' && steps.tested-verified.outputs.verified != 'true' }} + # A verified run whose report member is missing is an explicit + # unavailable-analysis answer, never a clean result. + - name: Refuse a verified run with no report + if: ${{ steps.source.outputs.report-available != 'true' }} run: | - echo "::error::refusing to publish: ${{ steps.tested-verified.outputs.refusal-code }}" + echo "::error::the producer run produced no aggregate document; nothing is published, and this is not a clean result" exit 1 - # `sha` is the commit that was ACTUALLY analysed: the verified merge - # commit when the producer recorded one, and otherwise the run head SHA - # that the first pass verified. Either way it is a value - # verify-source-run has tied to this pull request -- never the raw - # producer claim, and never the PR head substituted for the merge - # commit. - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/report@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: - report: ${{ steps.source.outputs.artifact-path }}/aggregate.json + report: ${{ steps.source.outputs.report-path }} repository: ${{ github.repository }} pr-number: ${{ steps.source.outputs.pr-number }} - sha: ${{ steps.tested-verified.outputs.tested-sha || steps.source.outputs.tested-sha }} + # The commit really analysed, as verified above -- never the raw + # producer claim, and never the PR head substituted for the merge + # commit. + sha: ${{ steps.source.outputs.tested-sha }} profile: ${{ env.ABICHECK_PROFILE }} - # Order the sticky comment by the PRODUCER's run, not this - # publisher's. Left unset these default to the publisher's own run - # id, which orders by when publication was triggered -- so a late - # re-run of an older commit would look newer than the current - # result and overwrite it, which is the inversion the guard exists - # to prevent. + # Order the sticky comment by the PRODUCER's run. Left unset these + # default to this publisher's own run id, which orders by when + # publication was triggered, so a late re-run of an older commit + # would look newer and overwrite the current result. source-run-id: ${{ github.event.workflow_run.id }} source-run-attempt: ${{ github.event.workflow_run.run_attempt }} - # `always`, not `changes`: a run whose comparison came back clean - # must still publish, otherwise the sticky comment left by an - # earlier run keeps showing a warning that no longer holds. An - # incomplete analysis already counts as a change, so `changes` - # would publish that -- it is specifically the clean resolution it - # would silently skip. - post-on: always + # A resolved result is cleared in place and a material analysis + # limitation posts, so `changes` does not leave a stale warning + # standing. A different notification policy is a project decision, + # not a workaround. + post-on: changes detail: standard run-label: >- run ${{ github.event.workflow_run.id }} @@ -184,18 +143,27 @@ jobs: job-summary: 'true' github-token: ${{ github.token }} - # A publication failure is an operational failure of this workflow and - # is reported as one. It is never allowed to look like a clean - # compatibility result, and a compatibility verdict never fails this - # workflow -- this is a reporting job, not a gate. + # A publication failure is an operational failure of this workflow and is + # reported as one. It is never allowed to look like a clean compatibility + # result, and a compatibility verdict never fails this workflow -- this + # is a reporting job, not a gate. - name: Report the publication outcome if: ${{ always() && steps.source.outputs.verified == 'true' }} + env: + POSTED: ${{ steps.publish.outputs.posted }} + SKIPPED: ${{ steps.publish.outputs.skipped-reason }} + PR: ${{ steps.source.outputs.pr-number }} + TESTED: ${{ steps.source.outputs.tested-sha }} + TESTED_SOURCE: ${{ steps.source.outputs.tested-sha-source }} + PROVENANCE: ${{ steps.source.outputs.provenance }} + PR_HEAD: ${{ steps.source.outputs.pr-head-sha }} + FORK: ${{ steps.source.outputs.from-fork }} + OUTCOME: ${{ steps.publish.outcome }} run: | set -eu - echo "posted=${{ steps.publish.outputs.posted }}" - echo "skipped-reason=${{ steps.publish.outputs.skipped-reason }}" - echo "pr=${{ steps.source.outputs.pr-number }} tested-sha=${{ steps.tested-verified.outputs.tested-sha || steps.source.outputs.tested-sha }} run-head=${{ steps.source.outputs.tested-sha }} pr-head=${{ steps.source.outputs.pr-head-sha }} from-fork=${{ steps.source.outputs.from-fork }}" - if [ "${{ steps.publish.outcome }}" != "success" ]; then + echo "posted=$POSTED skipped-reason=$SKIPPED" + echo "pr=$PR tested-sha=$TESTED source=$TESTED_SOURCE provenance=$PROVENANCE pr-head=$PR_HEAD from-fork=$FORK" + if [ "$OUTCOME" != "success" ]; then echo "::error::the ABI/API report could not be published (the analysis result itself is unaffected)" exit 1 fi diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index b67dd8ca1..658a378d7 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -35,6 +35,8 @@ env: # (shared) configuration, EPICS Base 7.0, and PVXS's own bundled # libevent rather than the system one. ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent + # One immutable, merged abicheck revision for every Action reference. + ABICHECK_REF: e38c3f91b8f956f7c8fa51b60d17fc4030969dec jobs: native: @@ -238,67 +240,30 @@ jobs: # ── ABI/API evidence capture (advisory; ABICC stays authoritative) ──── # - # This reuses the build this job has already done. There is no second - # build, no second dependency preparation and no source-tree input: the - # capture reads the headers in include/pvxs and the shared objects in - # lib/$EPICS_HOST_ARCH that `cue.py build` installed, plus the EPICS - # Base include roots `cue.py prepare` had already set up. + # Reuses the build this job already did: no second build, no second + # dependency preparation, no source-tree input. The capture reads the + # headers in include/pvxs and the shared objects in lib/$EPICS_HOST_ARCH + # that `cue.py build` installed, plus the EPICS Base include roots + # `cue.py prepare` set up. # # It runs last, after this job's own tests, so nothing else that needs # this checkout is disturbed, and it runs even when those tests failed # (an unrelated red test must not hide an ABI finding) as long as the - # build itself produced an installation to look at. + # build produced an installation to look at. # - # Depth is L2 -- exported symbols and debug info (L0/L1) plus the public - # header AST. That is a deliberate reduction from the previous - # integration, which rebuilt both revisions under Bear to reach L3: - # build-drift coverage is NOT part of this check any more. + # Depth is L2 -- exported symbols and debug info plus the public header + # AST. Build-flag and toolchain drift (L3) are not covered. - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: abi-capture if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' }} uses: ./.github/actions/abicheck-capture with: output-dir: ${{ runner.temp }}/abicheck-candidate - # The revision actually built and analysed here. For a - # pull_request this is the merge commit, NOT the PR head. + # The revision actually built and analysed here. For a pull_request + # this is the merge commit, NOT the PR head. project-ref: ${{ github.sha }} profile: ${{ env.ABICHECK_PROFILE }} - abicheck-ref: 80cf72abb5856eb623a6056fb35d06a66ad26774 - - - name: Record analysis context - id: abi-context - if: ${{ always() && steps.abi-capture.outcome == 'success' }} - env: - OUT: ${{ runner.temp }}/abicheck-candidate/analysis-context.json - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -eu - # Diagnostics only. Everything here is produced by an unprivileged - # job, so the trusted publisher re-derives the repository, run and - # pull request from the GitHub API and never trusts these values. - python3 - <<'EOF' - import json, os - doc = { - "note": "untrusted: produced by the analysis job, re-derive via the API", - "repository": os.environ["GITHUB_REPOSITORY"], - "run_id": os.environ["GITHUB_RUN_ID"], - "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], - "workflow_ref": os.environ["GITHUB_WORKFLOW_REF"], - "event": os.environ["GITHUB_EVENT_NAME"], - "build_sha": os.environ["GITHUB_SHA"], - "pr_number": os.environ.get("PR_NUMBER") or None, - "pr_head_sha": os.environ.get("PR_HEAD_SHA") or None, - "pr_base_sha": os.environ.get("PR_BASE_SHA") or None, - "pr_base_ref": os.environ.get("PR_BASE_REF") or None, - "profile": os.environ["ABICHECK_PROFILE"], - "abicheck_ref": "80cf72abb5856eb623a6056fb35d06a66ad26774", - } - with open(os.environ["OUT"], "w") as f: - json.dump(doc, f, indent=2, sort_keys=True) - EOF + abicheck-ref: ${{ env.ABICHECK_REF }} - name: Upload ABI snapshots if: ${{ always() && steps.abi-capture.outcome == 'success' }} @@ -316,34 +281,31 @@ jobs: # ── ABI/API comparison (advisory; ABICC stays authoritative) ──────────── # - # Consumes the snapshots the selected native leg captured in THIS run. - # It never extracts anything itself: both operands are snapshots, so no - # compiler, no castxml and no EPICS checkout are needed here. + # Consumes the snapshots the selected native leg captured in THIS run. Both + # operands of every comparison are snapshots, so no compiler, no castxml + # and no EPICS checkout are needed here, and each component is extracted + # once for both baseline channels. # # `needs: native` waits for the whole matrix -- GitHub cannot depend on a - # single leg -- but `if: always()` means an unrelated red leg (a flaky - # RTEMS test, an OSX build) never suppresses an available ABI finding. + # single leg -- but `if: always()` means an unrelated red leg never + # suppresses an available ABI finding. abicheck: name: ABI/API check (advisory) needs: native if: ${{ always() }} runs-on: ubuntu-latest - # Measured: ~4 min for both snapshot-to-snapshot comparisons plus - # staging, on a warm runner. Bounded well above that, but nowhere near - # wide enough to let a hung step masquerade as a narrowed scope. timeout-minutes: 25 permissions: contents: read - # Needed only to list this repository's own default-branch runs when - # resolving the accepted-main baseline. No write scope, no secrets. + # Only to list this repository's own default-branch runs when + # resolving the accepted-main baseline. No write scope, no secrets. actions: read env: REPORT_DIR: ${{ github.workspace }}/abicheck-reports - # Outside REPORT_DIR on purpose; actions/aggregate refuses a manifest - # inside the directory it globs, rather than aggregating it as a target. - # Not runner.temp: the `runner` context is not available in a job's - # env block, and referencing it there makes the whole workflow file - # invalid (the run fails instantly with no jobs). + # Outside REPORT_DIR: actions/aggregate refuses a manifest inside the + # directory it globs. Not runner.temp -- the `runner` context is not + # available in a job env block, and referencing it there makes the + # whole workflow file invalid. EXPECTED_TARGETS: ${{ github.workspace }}/abicheck-expected-targets.json CANDIDATE_DIR: ${{ github.workspace }}/abicheck-candidate BASELINE_RELEASE_DIR: ${{ github.workspace }}/abicheck-baseline-release @@ -355,19 +317,19 @@ jobs: - name: Download candidate snapshots id: candidate - # Narrow, deliberate: a missing artifact is a real outcome this job - # must report as an incomplete analysis, not a step that silently - # succeeds. Every later step branches on this outcome explicitly. + # A missing artifact is a real outcome this job reports as an + # incomplete analysis, not a step that silently succeeds. continue-on-error: true uses: actions/download-artifact@v7 with: name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} path: ${{ env.CANDIDATE_DIR }} - - name: Note a missing candidate capture - if: ${{ steps.candidate.outcome != 'success' }} - run: | - echo "::warning::No ABI snapshots were captured for profile $ABICHECK_PROFILE; every check will aggregate as unavailable." + # Both channel directories always exist, so a staging failure reaches + # resolve-baseline as its own `not_found` outcome on a declared check + # rather than as a skipped step that would vanish from the expected set. + - name: Prepare the baseline channel directories + run: mkdir -p "$BASELINE_MAIN_DIR" "$BASELINE_RELEASE_DIR" # ── Baseline staging ───────────────────────────────────────────── # Two distinct questions, two distinct channels, never merged: @@ -376,7 +338,6 @@ jobs: # # Eligibility is abicheck's (verify-baseline-source); fetching the bytes # is ours, because only we know where we published them. - - name: Stage the release-contract baseline if: ${{ steps.candidate.outcome == 'success' }} id: baseline-release @@ -385,10 +346,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -eu - mkdir -p "$BASELINE_RELEASE_DIR" asset="abicheck-baseline-${ABICHECK_PROFILE}.tar.zst" # gh defaults both exclusions to false, so --limit 1 alone can - # select a draft or a pre-release. The release contract is a + # select a draft or a pre-release. The release contract is a # published final release. tag=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 \ --exclude-drafts --exclude-pre-releases --json tagName \ @@ -401,15 +361,15 @@ jobs: gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ --pattern "$asset" --dir "$BASELINE_RELEASE_DIR" - # Which run may supply this pull request's base baseline. Repository, - # workflow, event, branch, head SHA and conclusion are all checked, and - # every rejection is printed -- so "no eligible baseline" can say what it - # did see. not_found and lookup_failed stay distinct outcomes. + # Which run may supply this pull request's base baseline. Repository, + # workflow, event, branch, head SHA and conclusion are all checked and + # every rejection is printed, so "no eligible baseline" can say what it + # did see; not_found and lookup_failed stay distinct outcomes. - name: Verify the accepted-main baseline source if: ${{ steps.candidate.outcome == 'success' && github.event_name == 'pull_request' }} id: base-source continue-on-error: true - uses: abicheck/abicheck/actions/verify-baseline-source@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: mode: producer-run workflow: .github/workflows/ci-scripts-build.yml @@ -426,7 +386,6 @@ jobs: RUN_ID: ${{ steps.base-source.outputs.run-id }} run: | set -eu - mkdir -p "$BASELINE_MAIN_DIR" if ! gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" \ --name "abicheck-candidate-${ABICHECK_PROFILE}" --dir "$BASELINE_MAIN_DIR"; then echo "::warning::eligible baseline run $RUN_ID has no abicheck-candidate-$ABICHECK_PROFILE artifact (expired or never produced)" @@ -434,9 +393,9 @@ jobs: fi # ── Comparison ─────────────────────────────────────────────── - # One check-target invocation per (component, channel). Every one of - # them reads the same two candidate snapshots captured once upstream: - # nothing here re-extracts, and nothing re-compares for rendering. + # One check-target invocation per (component, channel). All four read the + # same two candidate snapshots: nothing re-extracts, and nothing + # re-compares for rendering. # # Resolution runs the full per-target check set -- content digest, # profile, project_ref, generation, schema and path-escape -- on the @@ -444,7 +403,7 @@ jobs: - name: Locate candidate snapshots id: snapshots if: ${{ steps.candidate.outcome == 'success' }} - uses: abicheck/abicheck/actions/resolve-baseline@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/resolve-baseline@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: kind: members baseline-path: ${{ env.CANDIDATE_DIR }} @@ -454,10 +413,15 @@ jobs: expected-project-ref: ${{ github.sha }} expected-baseline-generation: '1' + # The accepted-main checks exist only on a pull request: there is no PR + # base to compare against on a push or tag build, and declaring them + # there would manufacture unavailable checks for a question the event + # never asked. That event policy is the `if:` below, and it is the only + # place it is stated. - name: 'libpvxs vs accepted-main' id: main-core - if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 + if: ${{ always() && steps.snapshots.outcome == 'success' && github.event_name == 'pull_request' }} + uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -476,8 +440,8 @@ jobs: - name: 'libpvxsIoc vs accepted-main' id: main-ioc - if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-main.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 + if: ${{ always() && steps.snapshots.outcome == 'success' && github.event_name == 'pull_request' }} + uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -496,8 +460,8 @@ jobs: - name: 'libpvxs vs release-contract' id: rel-core - if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 + if: ${{ always() && steps.snapshots.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -515,8 +479,8 @@ jobs: - name: 'libpvxsIoc vs release-contract' id: rel-ioc - if: ${{ always() && steps.snapshots.outcome == 'success' && steps.baseline-release.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@80cf72abb5856eb623a6056fb35d06a66ad26774 + if: ${{ always() && steps.snapshots.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -532,154 +496,82 @@ jobs: head-sha: ${{ github.sha }} severity-preset: default - # Which checks this event asks for is PVXS policy, so it stays here. - # The accepted-main checks exist only on a pull request: there is no PR - # base to compare against on a push or tag build, and declaring them - # there would manufacture unavailable checks for a question the event - # never asked. A check that ran but produced nothing is still declared, - # with an empty report, so it aggregates as unavailable rather than - # vanishing from the expected set. + # Each id below is check-target's OWN canonical check-id output. This + # step selects; it never constructs a target@profile#channel@depth + # string and never reconstructs the expected set. A step the event did + # not ask for contributes no id and is not declared; a step that ran but + # produced no report is declared with an empty report, so it aggregates + # as unavailable rather than vanishing. - name: Declare the checks this event asked for id: declare if: ${{ always() }} env: - EVENT: ${{ github.event_name }} - MAIN_CORE: ${{ steps.main-core.outputs.report-path }} - MAIN_IOC: ${{ steps.main-ioc.outputs.report-path }} - REL_CORE: ${{ steps.rel-core.outputs.report-path }} - REL_IOC: ${{ steps.rel-ioc.outputs.report-path }} + DECLARED: | + [{"id": "${{ steps.main-core.outputs.check-id }}", "report": "${{ steps.main-core.outputs.report-path }}"}, + {"id": "${{ steps.main-ioc.outputs.check-id }}", "report": "${{ steps.main-ioc.outputs.report-path }}"}, + {"id": "${{ steps.rel-core.outputs.check-id }}", "report": "${{ steps.rel-core.outputs.report-path }}"}, + {"id": "${{ steps.rel-ioc.outputs.check-id }}", "report": "${{ steps.rel-ioc.outputs.report-path }}"}] run: | - python3 - <<'PY' >> "$GITHUB_OUTPUT" - import json, os - p = os.environ["ABICHECK_PROFILE"] - checks = [] - if os.environ["EVENT"] == "pull_request": - checks += [ - {"id": f"libpvxs@{p}#accepted-main@headers", - "report": os.environ.get("MAIN_CORE", "")}, - {"id": f"libpvxsIoc@{p}#accepted-main@headers", - "report": os.environ.get("MAIN_IOC", "")}, - ] - checks += [ - {"id": f"libpvxs@{p}#release-contract@headers~release", - "report": os.environ.get("REL_CORE", "")}, - {"id": f"libpvxsIoc@{p}#release-contract@headers~release", - "report": os.environ.get("REL_IOC", "")}, - ] - print("checks=" + json.dumps(checks)) - PY - - # One aggregate document over every check this event declared. The - # publisher renders exactly this; it never re-runs a comparison and - # never classifies a verdict of its own. - # - # This runs even when no candidate was captured: an aggregate over - # entirely unavailable targets is the canonical way to say "the analysis - # did not complete", and it is the same document shape the publisher - # reads on the happy path. There is no second, divergent incomplete - # file. + set -eu + python3 -c 'import json,os; print("checks=" + json.dumps([c for c in json.loads(os.environ["DECLARED"]) if c["id"]]))' >> "$GITHUB_OUTPUT" + + # One aggregate document over every declared check. The publisher + # renders exactly this; it never re-runs a comparison and never + # classifies a verdict of its own. # # compatibility-exit carries abicheck aggregate's own 0/1/2/4 without - # failing the step, which is what keeps this shadow gate advisory. A + # failing the step, which is what keeps this shadow gate advisory. A # refused declaration, a usage error, or a document that does not - # describe a real outcome fails the step instead -- operational loss can - # no longer reach the publisher disguised as an empty finding set. + # describe a real outcome fails the step instead, so operational loss + # cannot reach the publisher disguised as an empty finding set. - name: Aggregate both components id: aggregate if: ${{ always() }} - uses: abicheck/abicheck/actions/aggregate@80cf72abb5856eb623a6056fb35d06a66ad26774 + uses: abicheck/abicheck/actions/aggregate@e38c3f91b8f956f7c8fa51b60d17fc4030969dec with: reports-dir: ${{ env.REPORT_DIR }} manifest-path: ${{ env.EXPECTED_TARGETS }} checks: ${{ steps.declare.outputs.checks }} - - - name: Summarise the aggregate outcome - if: ${{ always() && steps.aggregate.outcome == 'success' }} - run: | - echo "status=${{ steps.aggregate.outputs.status }}" \ - "coverage=${{ steps.aggregate.outputs.coverage }}" \ - "compatibility-exit=${{ steps.aggregate.outputs.compatibility-exit }}" \ - "analyzed=${{ steps.aggregate.outputs.analyzed }}/${{ steps.aggregate.outputs.expected }}" - echo "channels: ${{ steps.aggregate.outputs.channels }}" - - # The headline, above the rendered body, so the distinction between - # "compared, and compatible" and "compared nothing" survives a glance at - # a green advisory job. - # - # Every value here is a canonical output of actions/aggregate -- status, - # coverage, analyzed/expected and the per-channel roll-up. Nothing is - # re-derived from report files and no reason string is parsed: the - # reasons stay where the aggregate document and the renderer put them. - - name: State plainly whether a comparison happened + # Record WHICH revision this run analysed into the document itself, + # as five separate fields, so the trusted publisher reads it in one + # pass instead of parsing a sidecar in a privileged shell. On a + # pull_request run `github.sha` (the input default) is the ephemeral + # merge commit -- the same revision the capture recorded. It stays a + # CLAIM until the publisher verifies it against the API. + record-analysis-context: 'true' + profile: ${{ env.ABICHECK_PROFILE }} + orchestration-ref: ${{ env.ABICHECK_REF }} + + # One canonical bounded renderer: aggregate.txt is abicheck's own + # summary of the document, and the outputs beside it are the document's + # own. Nothing here re-derives a cause from report files, and nothing + # infers one from a count -- zero completed comparisons can mean a + # missing baseline, a failed capture or a corrupt report, and only the + # structured outcome knows which. + - name: Summarise the ABI/API outcome if: ${{ always() && steps.aggregate.outcome == 'success' }} env: + AGG: ${{ steps.aggregate.outputs.aggregate-path }} STATUS: ${{ steps.aggregate.outputs.status }} COVERAGE: ${{ steps.aggregate.outputs.coverage }} + EXIT: ${{ steps.aggregate.outputs.compatibility-exit }} ANALYZED: ${{ steps.aggregate.outputs.analyzed }} EXPECTED: ${{ steps.aggregate.outputs.expected }} CHANNELS: ${{ steps.aggregate.outputs.channels }} run: | set -eu { - if [ "$ANALYZED" -eq 0 ]; then - echo "## ABI/API comparison not performed: $ANALYZED/$EXPECTED checks completed." - echo - echo "Baselines unavailable; no compatibility verdict was produced." - elif [ "$ANALYZED" -lt "$EXPECTED" ]; then - echo "## ABI/API comparison incomplete: $ANALYZED/$EXPECTED checks completed." - echo - echo "Some baselines were unavailable; the verdict below covers only the checks that ran." - else - echo "## ABI/API comparison completed: $ANALYZED/$EXPECTED checks." - fi + echo "## ABI/API check (advisory)" echo - echo "\`status=$STATUS\` \`coverage=$COVERAGE\` \`channels=$CHANNELS\`" + echo "\`coverage=$COVERAGE\` \`status=$STATUS\` \`compatibility-exit=$EXIT\` \`analyzed=$ANALYZED/$EXPECTED\`" echo + echo "\`channels=$CHANNELS\`" + echo + echo '```' + cat "$(dirname "$AGG")/aggregate.txt" + echo '```' } >> "$GITHUB_STEP_SUMMARY" - # A bounded rendering of the aggregate belongs here, and upstream's - # actions/report `dry-run: true` is exactly that -- no API, no token, - # nothing posted. It cannot be used at this pin: the action fails - # TEMPLATE VALIDATION and the job dies in "Set up job", before any step - # runs: - # - # abicheck/abicheck//actions/report/action.yml (Line: 119, - # Col: 18): Unrecognized named-value: 'github'. Located at position 1 - # within expression: github.event.workflow_run.id - # - # The cause is prose, not logic. Two input DESCRIPTIONS quote - # `${{ github.event.workflow_run.id }}` and - # `${{ github.event.workflow_run.run_attempt }}` as usage examples - # (action.yml:122 and :133). Actions evaluates expressions inside input - # descriptions, and the `github` context does not exist in action - # metadata -- so the file cannot be loaded from any workflow, whatever - # inputs a caller passes. Passing the inputs explicitly does not help; - # validation happens when the file is parsed. - # - # It is unconditional and it is upstream's to fix: reported with this - # reproduction. It also means `abicheck-report.yml` cannot work as - # pinned either -- see documentation/abicheck.md. Nothing is - # reimplemented here to route around it; the headline above already - # states the outcome from the aggregate's own outputs. - - # Provenance for the trusted publisher, written into the REPORTS - # artifact -- the one the publisher actually downloads. It used to be - # written into the candidate artifact, which the publisher never fetches, - # so it could not have been used even in principle. - # - # `github.sha` here is the commit this analysis really ran against: on a - # pull_request run that is the ephemeral merge commit, which is also - # what the capture recorded as its project-ref. It is a CLAIM until the - # publisher verifies it -- verify-source-run checks it is the PR head or - # a merge of it, and refuses otherwise. Nothing downstream trusts this - # file; it only lets the publisher ask the API the right question. - - name: Record the analysed commit for the publisher - if: ${{ always() }} - run: | - set -eu - mkdir -p "$REPORT_DIR" - printf '%s\n' "$GITHUB_SHA" > "$REPORT_DIR/tested-sha.txt" - - name: Upload ABI reports if: ${{ always() }} uses: actions/upload-artifact@v7 diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 604e39bff..b43152aa8 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -1,521 +1,215 @@ # Advisory ABI/API check (abicheck) -PVXS's authoritative ABI check is ABICC, driven by `abi-diff.sh` from -`.github/workflows/release.yml`. Nothing here changes that. This document -describes the *advisory* abicheck integration that runs alongside it during -shadow adoption. +An advisory shadow check that reports ABI/API changes to `libpvxs` and +`libpvxsIoc` on a pull request. It never gates: `abi-diff.sh` (ABICC) remains +authoritative. This page is the maintenance guide — how it is wired, what it +compares against, how to read an outcome, and what it cannot yet do. -## What it does +## Setup - normal candidate build (one existing matrix leg) - -> capture libpvxs and libpvxsIoc once, at L2 - -> compare against reusable, explicitly identified baselines - -> retain canonical reports and diagnostics - -> trusted, report-only publisher - -> one pull-request comment covering both components +Three files describe the whole integration; everything else is a reference to +a pinned abicheck Action. +| File | Role | +|---|---| +| `.ci-local/abicheck-components.json` | which library owns which installed headers | +| `.ci-local/abicheck.yml` | how the public headers are parsed for extraction | +| `.github/actions/abicheck-capture/action.yml` | EPICS build-context resolution, then one `uses:` | -There is exactly **one build** and **one capture per component per -revision**. The capture is reused for every comparison, for the job summary -and for the pull-request comment. Rendering never re-extracts and never -re-compares. +Every abicheck Action is pinned to one merged, immutable revision, named once +per workflow as `ABICHECK_REF`. -## Build reuse +## Selected surface and profile -The capture runs inside the existing `Native Linux (WError)` leg of -`.github/workflows/ci-scripts-build.yml` — the one configuration carrying the -`abicheck: 1` marker. It reads what `cue.py build` installed: - -* `include/pvxs/*.h` — the installed public headers, including the generated - `versionNum.h` -* `lib/$EPICS_HOST_ARCH/libpvxs.so.*`, `lib/$EPICS_HOST_ARCH/libpvxsIoc.so.*` - — the real shared objects, not the symlinks and not the static archives -* the EPICS Base include roots `cue.py prepare` had already set up, read from - `configure/RELEASE.local` - -It does not reconstruct EPICS Makefile internals, copy headers out of `src/` -or `ioc/`, rewrite EPICS configuration, or move `HOME`. It runs after the -leg's own tests so nothing else that needs the checkout is disturbed, and it -runs even if those tests failed, as long as the build produced an -installation — an unrelated red test must not hide an ABI finding. - -`.ci-local/abicheck-components.json` declares the two components by pattern. -Resolving those patterns — picking the real shared object out of its SONAME -alias chain, checking it is an ELF `ET_DYN`, agreeing the target machine -between components, expanding the owned header sets and refusing a stale -exclusion — belongs to abicheck's `actions/baseline` (`library-spec`), not to -PVXS. - -Two assertions the declaration makes deliberately, because a glob alone would -lose them: - -* `include/pvxs/versionNum.h` is named explicitly as well as matched by the - `*.h` glob. A header pattern that matches nothing is a hard error, so a - build that did not generate it fails the capture instead of quietly - producing a 14-header surface in which every `versionNum` declaration reads - as removed. -* `header_exclude: include/pvxs/iochooks.h` is likewise an error if it matches - nothing, so if that header is ever renamed or moved, `libpvxs` cannot - silently re-acquire its sibling's declarations. - -What is left in `.github/actions/abicheck-capture` is PVXS's own build-system -knowledge and nothing else: where `cue.py` recorded `EPICS_BASE`, which -`EPICS_HOST_ARCH` it built for, the native-Linux guard, and rendering those -two values into the declaration. - -## Ownership - -| Component | Owned public surface | Include-only context | -|---|---|---| -| `libpvxs` | Every installed `include/pvxs/*.h` except `iochooks.h` | `include/`, EPICS Base `include/`, `include/os/Linux`, `include/compiler/gcc` | -| `libpvxsIoc` | `include/pvxs/iochooks.h` only | the same roots, plus the installed core headers | +`libpvxs` owns every installed `include/pvxs/*.h` except `iochooks.h`; +`libpvxsIoc` owns only `iochooks.h`. The installed core headers and the EPICS +Base include roots are *parser context* for both — an include root never +widens what a component is held responsible for. -An include search root is context for the parser. It is not a declaration -that the component must export everything reachable through it. See "Known -issues" — abicheck does not honour that distinction correctly yet. +Two assertions in the declaration are deliberate, because a glob alone would +lose them and a pattern matching nothing is a hard error upstream: -## Evidence depth +- `versionNum.h` is named explicitly *as well as* matched by `*.h`. Without + that, a missing generated header resolves silently to the remaining + headers and every `versionNum` declaration reports as removed. +- `header_exclude` on `iochooks.h` fails if that header ever moves, so + `libpvxs` cannot quietly re-acquire its sibling's declarations. -`--depth headers` (L2): exported symbols and DWARF (L0/L1) plus the public -header AST. - -This is a **deliberate reduction** from the previous integration, which ran -Bear over a rebuild of both revisions to reach L3. Build-flag and toolchain -drift are **no longer covered**. L4 and L5 source evidence are not collected -and must not be enabled implicitly. Debug information is whatever the normal -build produced; no special build is performed to obtain it, and coverage is -reported as it actually is. - -## Extraction context - -`.ci-local/abicheck.yml` pins `-std=c++17` for header parsing only. PVXS's -supported consumer mode and its own build are C++11; parsing the public -headers in C++11 or C++14 against the runner's libstdc++ 13 is not possible -with the supported CastXML (0.7.0, Clang 20). This is a disclosed deviation -of the extraction context, applied identically to both components and to both -sides of every comparison. `PVXS_API_BUILDING` and `PVXS_ENABLE_EXPERT_API` -are deliberately not defined: the headers are parsed as an ordinary consumer -sees them. - -## Baselines - -Two separate questions, never merged into one number: - -* **accepted-main** — what did this pull request introduce? Resolved by - downloading the `ci-scripts-build.yml` run for the pull request's *exact - base commit*, and rejected by `check-target` if the baseline's recorded - `project_ref` is not that commit. No cache restore-key prefix matching. -* **release-contract** — what changed since the selected supported release? - A `abicheck-baseline-.tar.zst` release asset, published by - `.github/workflows/abicheck-baseline.yml` from a tag build only. - -Both are resolved by explicit revision, component and profile. The profile -id — `linux-x86_64-gcc-default-base7.0-bundled-libevent` — names the build -arrangement, including PVXS's bundled libevent rather than the system one, so -a baseline from a different arrangement resolves as `wrong_profile` rather -than being silently compared. - -Selection by base SHA alone is not sufficient, so an accepted-main -baseline must additionally come from this repository's own -`ci-scripts-build.yml`, from a push to the pull request's base branch, and -from a run that **concluded success**. Without the last two, a failed run's -snapshot could become the baseline — the capture step deliberately still -runs after a test failure. That eligibility decision is -`actions/verify-baseline-source` (`mode: producer-run`), which also prints -every candidate it rejected and why, so "no eligible baseline" can say what -it did see. "No eligible run", "the lookup failed" and "the artifact -expired" remain three different outcomes; PVXS only fetches the bytes once -the verifier reports `eligible`. - -Tag identity for the release channel is the same Action in `mode: tag`. It -resolves `refs/tags/` explicitly rather than the commits endpoint -(which would happily resolve a branch), peels an annotated tag, assumes no -`v` prefix — PVXS tags are bare versions like `1.5.2` — and requires the tag -to name the exact commit that was built. - -A missing, expired, wrong-profile or incompatible baseline produces an -explicit unavailable/incomplete outcome. It never produces a clean result. - -### What the baseline publisher refuses - -`.github/actions/abicheck-publish-baseline` validates a set before any of -it is staged or uploaded. It checks the manifest's `profile`, that both -components are present, and that the manifest's `project_ref` is the commit -the requested tag actually names — resolving `refs/tags/` explicitly -rather than through the commits endpoint (which would resolve a branch of -the same name) and peeling an annotated tag rather than accepting the tag -object's own sha. - -The revision check exists because nothing downstream repeats it. The -accepted-main comparisons pass `expected-project-ref` to `check-target`, -since a pull request knows exactly which commit its baseline must describe. -The release-contract comparisons cannot, because the release is chosen at -consumption time — so they deliberately pass no expected ref. The publisher -is therefore the only point where a set claiming the wrong revision can be -caught, on either publishing path. - -That matters most for the bootstrap below, which runs a historical -revision's own build system in the shared workspace before the runner loads -the capture action. Splitting the jobs put the write token out of that -code's reach; it did not make the artifact it produces trustworthy. The -claim is verified where both paths converge rather than trusted from the -producer. - -### Bootstrapping a historical release - -A release that predates this integration has no capture, and re-dispatching -the old workflow cannot produce one — the workflow *at that tag* has no -capture step. `abicheck-baseline.yml`'s `workflow_dispatch` path therefore -builds the requested revision once, in the trusted default-branch workflow, -captures it with the same shared action and the same component declaration -the matrix leg uses, and publishes the result. It is a one-time operation -per release and never runs on a pull request. - -That path is **two jobs, deliberately**. The build job runs the requested -revision's own code — its `cue.py`, its submodules, its makefiles — and so -holds `contents: read` and nothing else. It hands the captured baseline-set -to the second job as an artifact. The publishing job holds `contents: -write` but executes none of that code: it consumes the artifact and -nothing else. - -"Checks out the default branch" is enforced, not assumed. `workflow_dispatch` -runs from whichever ref was selected, and a checkout with no `ref:` takes -*that* ref — so the publishing job both refuses to run unless `github.ref` -is the default branch, and names the default branch explicitly in its -checkout. Without that, dispatching from any branch would load -`./.github/actions/abicheck-publish-baseline` from that branch into the job -holding `contents: write` and `github.token`. - -The build job is deliberately left unrestricted: it is read-only, so -dispatching it from a branch to exercise the capture is safe, and the -publish job simply does not run. - -Without the split, historical code could overwrite -`.github/actions/abicheck-publish-baseline` in the shared workspace before -the runner loads it, and that local action is handed `github.token`. -`persist-credentials: false` does not prevent that — it stops the historical -checkout receiving credentials, not a later step loading a tampered local -action. - -The component declaration is read from the default-branch checkout, not -from the historical tree, which predates the file entirely. - -## Publication - -`.github/workflows/abicheck-report.yml` is a separate, trusted publisher: - -* The analysis workflow has `contents: read` and no secrets. It is never - given write access to make a comment work, and contributor code is never - run under `pull_request_target`. -* The publisher runs from the default branch on `workflow_run`, with only - `actions: read` and `pull-requests: write`. -* It delegates to two reviewed abicheck Actions rather than growing its own - logic: `actions/verify-source-run` establishes which run this is, which - pull request it belongs to and which commit was actually analysed — all - from the GitHub API, never from the artifact — and extracts the artifact - under size, entry-count and compression-ratio caps; - `actions/report` renders the producer's canonical aggregate document and - maintains the sticky comment. -* The pull request head SHA and the commit that was actually analysed are - separate coordinates. For a `pull_request` producer the analysed commit - is the ephemeral merge commit, not the PR head, and the comment shows the - analysed one. They are never substituted for each other. -* Publication is bound to the producer attempt that triggered it, and - concurrency is keyed per pull request and profile. The sticky comment's - ordering guard is given the *producer's* run id and attempt, not the - publisher's: ordering by the publisher would let a late re-run of an - older commit overwrite a newer result. -* It never checks out, installs, imports or executes pull-request code or - pull-request-built binaries, and it runs no analysis. -* A publication failure fails visibly and is never reported as a clean - compatibility result. A compatibility verdict never fails the publisher. - -A trusted reporter does not make contributor-produced report contents trusted -evidence. Origin and assurance are preserved; what is enforced is who the -result is delivered to and what executes while delivering it. - -**Deployment prerequisite:** `workflow_run` only ever runs the copy of the -publisher on the default branch. Until a maintainer merges it there, no pull -request — including the one introducing it — will publish a comment. - -## Incomplete analyses - -There is one document shape, not two. When no candidate capture exists, or -a comparison did not run, the expected checks are still declared and -`abicheck aggregate` produces an aggregate document in which those targets -are `unavailable`. That is the same shape the publisher reads on the happy -path, so a producer failure cannot arrive at the publisher as a file it -does not understand. `actions/aggregate` validates the document before it is -handed downstream and separates the axes: `compatibility-exit` carries -`abicheck aggregate`'s own 0/1/2/4 without failing the step, which is what -keeps this gate advisory, while a refused declaration, a usage error or a -document that does not describe a real outcome fails the step. Operational -loss cannot reach the publisher disguised as an empty finding set. - -A report that ran and produced garbage is tracked as `unusable`, separately -from one that never ran at all. Both stay declared expected, so one -component's failure never costs the others their diagnostics, and `channels` -splits the roll-up per baseline channel — "the release comparison is -missing" is distinguishable from "one component is missing". - -Only the checks an event actually has are declared: the accepted-main -comparisons exist on a pull request, not on a push or tag build. - -## Gate policy - -Advisory (`gate-mode: advisory`) during shadow adoption. Gate status and -compatibility are reported separately: the check can be green while -prominently reporting a detected break. ABICC remains authoritative. - -## What has actually been compared - -Two different questions, kept apart on purpose: what this branch's **CI** has -analysed, and what has been **measured locally** on real builds. - -### In CI: nothing yet, and the green advisory check does not say otherwise - -Every run of this branch reports `status=fail coverage=empty, -0/4 target(s) analyzed`, `channels: {accepted-main: {analyzed: 0, -unavailable: 2}, release-contract: {analyzed: 0, unavailable: 2}}`. The job -concludes success because the gate is advisory and operational loss is -reported as loss. Since this branch also renders that state into the job -summary, a reader no longer has to open `aggregate.json` to discover it: - -``` -ABI/API comparison not performed: 0/4 checks completed. -Baselines unavailable; no compatibility verdict was produced. -``` - -Both channels are unavailable for reasons this pull request cannot fix, and -one of them is structural rather than transient: - -| Channel | Why unavailable | What would change it | -|---|---|---| -| accepted-main | The pull request's base commit `b5024df` is on `master`, whose `ci-scripts-build.yml` contains **no abicheck integration at all** — so no push run of that revision ever produced, or could produce, a candidate artifact. `verify-baseline-source` additionally refuses master's one run by name (`rejected run 35112205613: wrong-conclusion: the source run concluded 'failure'`), but fixing that run would not help: the artifact it would need does not exist in that revision's workflow. | This integration reaching the default branch, after which the base commit of the next pull request has a real capture. | -| release-contract | The fork has tags (`1.5.2`, `1.5.1`, …) but **zero GitHub Releases**, so there is no release to carry an asset and nothing for `resolve-baseline` to fetch. | An authorized one-time bootstrap: create the release for the selected tag, then dispatch `abicheck-baseline.yml` for it. | - -Neither is worked around. No baseline is fabricated, no comparison is -quietly skipped, and the missing coverage is reported as missing. - -### Locally: four real comparisons, on real builds - -To show the comparison path actually works — rather than only that the -plumbing is wired — all three revisions were built and captured with the -same declaration and the same pinned toolchain CI selects (CastXML -`0.6.20260105-g9864b1e`, bundled Clang `21.1.8`, GCC 13.3.0, EPICS Base 7.0, -bundled libevent at the identical submodule commit in all three revisions, -so the profile really is constant). - -| Check | Old side | Verdict | Gating | Public +/−/mod | -|---|---|---|---|---| -| libpvxs vs accepted-main | base `b5024df` | **COMPATIBLE** | 0 | 0 / 0 / 0 | -| libpvxsIoc vs accepted-main | base `b5024df` | **COMPATIBLE** | 0 | 0 / 0 / 0 | -| libpvxs vs release-contract | tag `1.5.2` (`8e00eae`) | **BREAKING** | 2 | 1 / 2 / 0 | -| libpvxsIoc vs release-contract | tag `1.5.2` (`8e00eae`) | **COMPATIBLE** | 0 | 0 / 0 / 0 | - -**The two channels disagree, and that is the point.** Against its own base -this pull request introduces nothing — as it should, since it changes no -runtime source. Against release 1.5.2 `libpvxs` is breaking, because two -weak typeinfo symbols for a lambda inside -`SharedPV::Impl::connectSub(...)` are present in 1.5.2 and gone afterwards. -That was confirmed independently of abicheck with `nm -D`: both symbols are -in 1.5.2's binary and absent from **both** the base and the head binary — so -the removal happened somewhere between 1.5.2 and `master`, and is not this -pull request's doing. A release-relative finding is drift since the release, -never automatically a change this pull request introduced. - -### Controls - -| Control | Result | -|---|---| -| Independent rebuild, same revision | COMPATIBLE, 0 gating, 100% binary compatibility | -| Disposable public break (`testAfterShutdown()` removed from the binary) | detected `func_removed`, severity `breaking`, `artifact_proven`, gating 1, exit 4 | -| Disposable compatible addition (`abicheckProbe(long)`) | detected `func_added`, severity `compatible`, non-gating | -| Snapshot independence | comparison succeeded in 1s after the source and install trees were deleted, with no re-extraction | -| Test mutations in shared source | none: the controls lived in a throwaway worktree, and `HEAD` still declares `testAfterShutdown()` and knows nothing of `abicheckProbe` | +One profile is analysed — +`linux-x86_64-gcc-default-base7.0-bundled-libevent`: native Linux x86_64, +GCC, shared (default) configuration, EPICS Base 7.0, PVXS's bundled libevent. +That name is the compatibility key; snapshots and baselines must share it. -The break control also produced an unplanned `exported_not_public` finding -for the helper the control renamed, which is the tool correctly objecting to -a symbol exported without a public declaration. +## Build reuse -### What the risk-change counts actually mean +The capture runs inside the existing `Native Linux (WError)` matrix leg — the +one carrying the `abicheck: 1` marker — after that leg's own tests. There is +no second build, no second dependency preparation and no source-tree input: +it reads the headers in `include/pvxs` and the shared objects in +`lib/$EPICS_HOST_ARCH` that `cue.py build` installed. -Every comparison above also reports 339–517 non-gating `risk` changes. The -independent-rebuild control puts a number on how much of that is signal: -rebuilding **the same revision** and comparing it against itself still -produces **339** risk changes for `libpvxsIoc`. That is the noise floor of -the known upstream attribution defect (dependency and sibling symbols -attributed to the component), not change. It is why this integration stays -advisory, and why those counts are reported but not gated. +It runs even when that leg's tests failed, as long as the build produced an +installation: an unrelated red test must not hide an ABI finding. Each +component is captured **once** and the resulting snapshots are reused for +both baseline channels and for rendering; nothing re-extracts. -### Measured separately +## Evidence depth -| Stage | Time | -|---|---| -| Build (per revision, EPICS Base and libevent already present) | 46–50 s | -| Capture `libpvxs` (15 headers, ~120 MB snapshot) | 67 s | -| Capture `libpvxsIoc` (1 header, ~2.3 MB snapshot) | 7 s | -| Compare `libpvxs` | 131–133 s | -| Compare `libpvxsIoc` | 2 s | -| Compare from snapshots after the trees were deleted | 1 s | - -The abicheck job's earlier ≈1-minute wall time is not a comparison -benchmark: that job performed **zero** comparisons. Snapshot compression -changes stored bytes, not extraction time. - -### A measurement error worth recording - -The first run of this exercise reported `libpvxsIoc` **BREAKING against its -own base**, which is impossible for a pull request that changes no runtime -source. The cause was not the tool: the shared checkout's *installed* -`include/` tree (a gitignored build output) still held an earlier session's -disposable controls — `testAfterShutdown()` deleted and `abicheckProbe(long)` -added — while its source and binary were clean. The candidate snapshot was -therefore taken from a stale, mutated install tree. - -The finding was real for the inputs given; the inputs were wrong. Every -number above was re-measured from freshly built worktrees, and the stale -install tree has been refreshed. The git source was never affected. This is -the concrete reason capture must be pinned to the build that produced it -rather than to whatever happens to be lying in an install directory. - -## Blocking upstream defect: `actions/report` cannot be loaded - -`abicheck/abicheck/actions/report` fails GitHub Actions **template -validation** at the pinned revision, so any job referencing it dies in -`Set up job` before a single step runs: - -``` -abicheck/abicheck//actions/report/action.yml (Line: 119, Col: 18): -Unrecognized named-value: 'github'. Located at position 1 within -expression: github.event.workflow_run.id -``` - -The cause is prose, not logic. Two input **descriptions** quote -`${{ github.event.workflow_run.id }}` and -`${{ github.event.workflow_run.run_attempt }}` as usage examples -(`action.yml:122` and `:133`). Actions evaluates `${{ }}` inside input -descriptions, and the `github` context does not exist in action metadata. -`${{ }}` is legal in `inputs.*.default` and `outputs.*.value` — and -`actions/check-target` uses `default: ${{ github.repository }}` perfectly -happily — but not in a description. - -It is unconditional: no caller, no set of inputs and no trigger avoids it, -because validation happens when the file is parsed. `actions/report` is the -only abicheck Action with an expression in a description, and **no abicheck -workflow references `actions/report`**, which is why it has no in-repo -consumer to have caught it. - -What it costs here, stated plainly: - -| Path | Effect | -|---|---| -| Bounded rendering in the read-only analysis job | removed — the job could not start. The canonical headline above still reports the outcome from `actions/aggregate`'s own outputs. | -| `abicheck-report.yml`, the trusted publisher | **cannot work as pinned.** Its `actions/report` step would fail the same way. The publisher has never run — it is `workflow_run`-only and not yet on a default branch — so this was never observed. | - -So the publication path is blocked on upstream twice over: it needs a -default-branch deployment *and* it needs this defect fixed. Neither is -worked around here; deleting two expression markers from someone else's -documentation is upstream's call, and vendoring a patched copy of a -published Action is exactly the local reimplementation this integration -exists to remove. - -Reported upstream with the reproduction above. - -## Dependency status - -Every abicheck Action is pinned to one merged, immutable revision: -`80cf72abb5856eb623a6056fb35d06a66ad26774` on abicheck `main`. It carries -[#1315](https://github.com/abicheck/abicheck/pull/1315) (`actions/aggregate`, -`actions/verify-baseline-source`, `library-spec` resolution) on top of -[#1311](https://github.com/abicheck/abicheck/pull/1311) (report-only -publication and the aggregate-shaped PR comment), plus -[#1319](https://github.com/abicheck/abicheck/pull/1319). Nothing here depends -on an unmerged revision, a mutable branch, or a placeholder ref. - -**#1319 is why this pin moved, and it fixed a live defect here.** At the -previous pin, `actions/report` declared and documented `source-run-id` / -`source-run-attempt`, and its `run.sh` read `INPUT_SOURCE_RUN_ID` — but -`action.yml` never forwarded the inputs into the step's environment. The -values this workflow passes were therefore discarded, and the ordering guard -silently fell back to `GITHUB_RUN_ID`: the *publisher's* run, which orders by -when publication was triggered rather than when the analysis ran. That is the -precise inversion the guard exists to prevent, and it is what the caller here -was written to avoid. The guard was inert until this pin. - -The one local composite Action that remains for a generic reason is -`.github/actions/abicheck-publish-baseline`. - -abicheck [#1319](https://github.com/abicheck/abicheck/pull/1319) added the -replacement this was waiting for — `publish-baseline.yml` now takes -`baseline-set-artifact-prefix` and will publish an already-captured -baseline-set, validating it against the profile, release tag and generation -it is being published as. That is strictly stronger than the local action. - -It does not yet fit **both** of this integration's publication paths, and -adopting it for one would leave two publishers, which is the duplication -this integration exists to remove: - -| Path | Where the set lives | Fits upstream? | +`--depth headers` (L2): exported symbols and DWARF, plus the public header +AST. **Build-flag and toolchain drift (L3) are not covered**, and L4/L5 +source evidence is neither collected nor enabled implicitly. Absent optional +evidence stays honestly absent. + +## Baseline policy + +Two questions, separately labelled, never merged into one number: + +| Channel | Old side | Answers | +|---|---|---| +| `accepted-main` | the `ci-scripts-build.yml` push run for the PR's **exact base commit** | what did this pull request introduce? | +| `release-contract` | a release asset published only from a tag build | what changed since the selected supported release? | + +`accepted-main` is declared **only on pull-request events** — a push or tag +build has no PR base, and declaring it there would manufacture a missing +check for a question nobody asked. + +Eligibility is `actions/verify-baseline-source`, not shell here: tags are +resolved through `refs/tags/` explicitly (a revision endpoint would +resolve a *branch*), annotated tags are peeled, no `v` prefix is assumed +(PVXS tags are bare versions such as `1.5.2`), the capture must belong to the +tagged commit, and `not_found` stays distinct from `lookup_failed`. PVXS +still fetches the bytes; only the decision is upstream's. + +A missing, expired, wrong-profile or incompatible baseline is an explicit +incomplete outcome, never a clean result. + +## Baseline publication and bootstrap + +`.github/workflows/abicheck-baseline.yml` publishes the `release-contract` +asset through abicheck's own reusable `publish-baseline.yml`, which owns +packaging, the manifest/schema/profile/generation/digest gate, tag resolution +and the immutability chain (identity-verified idempotent republish, +fail-closed on a conflicting asset — not filename-only idempotence, and no +silent clobber). PVXS has no publisher of its own. + +- **Automatic.** A tag build already captured the tagged revision. A + `mode: tag` gate confirms the pushed ref really is a tag naming the commit + that was built; publication then takes the bytes from that + already-completed producer run through the API, with the run's repository, + workflow, event, id, attempt and conclusion each verified and the artifacts + selected by their own ids. A pull-request-triggered producer is refused + unconditionally. +- **Bootstrap** (`workflow_dispatch`, one-time per release). A historical + release predates the integration, so no tag build of it ever produced a + set, and dispatching the old workflow cannot help — the workflow *at* that + tag has no capture step. This path builds the requested revision once with + PVXS's normal build commands and the same component declaration as the + candidate, then captures it. + +The publication tag and the revision a set records are two different +identifiers: `expected-project-ref: commit` resolves the tag and requires the +set's own `project_ref` to equal that commit. A SHA-valued manifest is never +rewritten to satisfy a tag-valued validator. + +## Permissions and deployment + +| Job | Scope | Runs | |---|---|---| -| Bootstrap (`workflow_dispatch`) | artifact in the *same* run, from `bootstrap-build` | **yes** | -| Tag publication (`workflow_run`) | artifact in the *producing* run | **no** | - -The blocker is one input, and it is unchanged as of abicheck `main` -(`db3b12c`): `publish-baseline.yml` downloads the set with -`actions/download-artifact` using `pattern:`/`name:` alone, with no `run-id` -and no token, so it can only see artifacts of the run it is executing in. -Re-checked at both this pin and `main` — `actions/` and -`publish-baseline.yml` are byte-identical between the two, so there is -nothing newer to adopt and no reason to move the pin for it. A `workflow_run` -publisher is by construction a *different* run from the one that captured -the set — that separation is the security boundary, not an accident, so -moving the capture into the publishing run is not an option. - -What would close it: a `run-id` (and token) passthrough on the pre-captured -path, or the ability to hand the workflow an already-downloaded directory. -Either would let both paths use one publisher and retire this action. That -is the remaining upstream dependency; it is not worked around here. - -## Known issues (abicheck product bugs, re-measured 2026-09-16 on `0b50f80`) - -Re-measured on this branch with abicheck -`0b50f807c8ea05719e414e78564c31ef32a2ea4e` and CastXML 0.7.0, by comparing -each component's snapshot against itself — a byte-identical pair, where the -only correct answer is "no change". - -What the merged revision fixed: nothing these two produce now gates or is -presented as a change. Both self-comparisons return verdict `NO_CHANGE` -with `0` gating findings, and the rendered PR comment says so explicitly — -"♻️ 845 pre-existing cross-source hygiene findings present on both sides — -not introduced by this change", with an audit line reading `845 detected · -0 gating · 845 non gating`. - -What is still wrong, at the detection layer: - -1. **Include-context headers are still charged to the component.** EPICS - Base symbols (`epicsMutex::lock()`, `epicsEvent::wait()`, `errVerbose`, - …) and libstdc++ internals still appear in `libpvxs`'s own itemized - list, and `pvxs::version_str()`/`version_int()`/`version_abi_int()` — - declared in `pvxs/version.h`, which `libpvxs` owns — appear in - `libpvxsIoc`'s. All are reached only through `-I` include roots, not - through the component's own declared public surface. -2. **Persistent hygiene findings are still detected on an unchanged pair.** - An unchanged `libpvxs` detects 505 findings and `libpvxsIoc` 340, listed - as 505 and 340 "Modifications" in the per-component review rendering, - although both fold to zero gating findings. - -So the effect on a pull-request comment is now bounded and honestly -labelled, but the underlying attribution is still wrong and the full -per-component report is still 845 items of noise. Both remain fixes owed by -abicheck, not by suppressions here. This integration stays advisory until -they are fixed. - -### Capture flake worth watching - -One `abicheck dump` invocation in this re-measurement failed with "CastXML -of unknown version was found", from the same CastXML 0.7.0 binary that a -`--version` probe and an immediately following dump both accepted. It has -been seen once and did not reproduce on retry. If a capture leg fails that -way in CI, it is this, not a real toolchain problem — but the leg fails -loudly rather than degrading to "no findings", which is the intended -behaviour. +| capture + comparison (`ci-scripts-build.yml`) | `contents: read`, `actions: read`, no secrets | contributor code, including forks | +| `bootstrap-build` | `contents: read` | the historical revision's own build code | +| publication (`abicheck-baseline.yml`) | `contents: write`, `actions: read` | reviewed pinned code only | +| `abicheck-report.yml` | `actions: read`, `pull-requests: write` | reviewed pinned code only | + +The read-only build and the write-capable publication are separate jobs, and +the write-capable half refuses to run off anything but the default branch. +Contributor code never runs with write credentials, and +`pull_request_target` is never used. + +**Deployment prerequisite:** `workflow_run` only ever runs the default-branch +copy of a workflow file. Until a maintainer merges `abicheck-report.yml` +there, no pull request — including the one that adds it — publishes a +comment. That is by design. + +## Interpreting an outcome + +The producer writes one canonical aggregate document over every declared +check and renders abicheck's own bounded summary of it into the job summary. +Read these four values: + +- `coverage` — `complete`, `partial` or `empty`. **`empty` means no + comparison was completed.** The cause comes from the document's structured + outcomes, not from the count: zero analyses can mean an unavailable + baseline, a failed capture, or a corrupt report. +- `status` — the document's own `pass`/`fail`. +- `compatibility-exit` — `abicheck aggregate`'s own `0`/`1`/`2`/`4`. It never + fails the step; that is what keeps this advisory. +- `channels` — the roll-up per baseline channel, so "the release comparison + is missing" is distinguishable from "one component is missing". + +A refused declaration, a usage error, or a document that does not describe a +real outcome **fails** the aggregate step, so operational loss cannot reach +the publisher disguised as an empty finding set. + +Every comparison also reports a few hundred non-gating `risk` changes: an +independent rebuild of a revision compared against *itself* still yields +several hundred, so that is the noise floor of a known upstream attribution +defect, not change — and part of why this stays advisory. + +## Publication to the pull request + +The analysis is unprivileged and cannot comment. `abicheck-report.yml` is a +separate trusted `workflow_run` publisher built from two reviewed Actions: +`verify-source-run` establishes which run, which pull request and which +commit was actually analysed — from the API, never from the artifact — and +`report` renders the document as one sticky comment per PR and profile, +covering both components with the baseline channels separately labelled. + +The artifact is acquired **once**: `provenance-from` reads the producer's +recorded `analysis_context` out of the same extraction, and `report-from` +returns the document together with the identity it was checked under. On a +`pull_request` run the analysed revision is an ephemeral merge commit no API +endpoint names; `require-provenance: true` means an artifact recording no +context is refused rather than silently reported as the PR head. Ordering is +by the **producing** run, so a late re-run of an older commit cannot +overwrite a newer result. + +Safe publication is not independent attestation: the report's contents remain +contributor-generated evidence. What is enforced is the recipient and the +execution boundary. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| `coverage=empty`, no comparison | no candidate artifact (capture failed or the leg did not run), or both channels unavailable — read `channels` | +| accepted-main unavailable on a PR | the base commit has no successful push run carrying a capture artifact, or it expired | +| release-contract unavailable | no published final release carries the asset for this profile — run the bootstrap once | +| capture refuses by name | a declared header or exclusion pattern matched nothing (usually `versionNum.h` not generated, or `iochooks.h` moved) | +| `wrong_profile` / `stale_generation` | the baseline was captured under a different build arrangement or scanner generation | +| a comparison looks wrong after a rebuild | check for a stale mutated `include/` tree in a shared checkout; it is a gitignored build output | + +## Current limitations + +- **`actions/report` cannot be loaded at the pinned revision.** Two of its + input *descriptions* quote `${{ github.event.workflow_run.id }}` and + `${{ github.event.workflow_run.run_attempt }}` as usage examples. Actions + evaluates expressions inside action metadata, and the `github` context does + not exist there, so the file fails template validation and the job dies in + "Set up job" before any step runs. It is unconditional, independent of the + inputs a caller passes, and upstream's to fix; `abicheck-report.yml` + therefore cannot publish yet. Nothing is reimplemented here to route around + it — the job summary states the outcome from the aggregate's own outputs. +- **C++17 extraction.** PVXS's supported consumer language mode is C++11, but + the extraction pipeline cannot parse these headers against libstdc++ 13 in + C++11 or C++14. C++17 extraction is a disclosed deviation and is **not** + validation of C++11 consumer behaviour. +- **Ownership attribution.** EPICS Base and libstdc++ symbols reached only + through `-I` roots are still attributed to `libpvxs`, and `pvxs::version_*` + to `libpvxsIoc`. Both fold to zero gating findings and are labelled + pre-existing on both sides. No suppressions are added here. +- **No published baselines yet.** The fork has tags but no GitHub Releases + carrying the asset, and `master` does not yet contain the integration, so + no push run of a base commit has produced a candidate artifact. Both are + reported as missing coverage rather than worked around. + +Measurements, comparison transcripts and the upstream issue references live +in the pull request discussion, not here. From daffed40ad763d10bb50ef2aeffe04d3469bf52e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:42:50 +0000 Subject: [PATCH 42/44] ci: declare the expected checks independently of comparison execution Deriving the aggregate declaration from check-target's own check-id outputs lost the expected set in the one case that matters most: when the candidate download or snapshot resolution fails, every comparison step is skipped and emits no id, so the filter reduced the declaration to an empty list and the run reported a shorter set of checks than it actually had -- an ABI analysis failure disappearing from the advisory result. Declare from the event's own policy instead, unconditionally, with the report path left empty when a comparison produced nothing: that is a declared-but-unavailable check, which is what the aggregate contract expects. The ids are still cross-checked upstream, where a report recording its own target_id is authoritative and a disagreeing declaration is refused rather than silently relabelled. Verified by running the step against three inputs: a pull_request event with every comparison skipped now declares 4 checks with empty reports (previously 0), the same event with reports declares 4 with paths, and a push event declares only the 2 release-contract checks. Also corrects the publication section of documentation/abicheck.md: the producer run and its pull request come from the API, but the analysed commit is read from aggregate.json and then verified against the API -- the text had the trust boundary backwards, and stated it accurately only in the following paragraph. Both found by CodeRabbit on #4. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q2Herd15iLCwK3ainaw52a --- .github/workflows/ci-scripts-build.yml | 56 ++++++++++++++++++-------- documentation/abicheck.md | 8 ++-- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 658a378d7..75c887e2c 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -413,11 +413,6 @@ jobs: expected-project-ref: ${{ github.sha }} expected-baseline-generation: '1' - # The accepted-main checks exist only on a pull request: there is no PR - # base to compare against on a push or tag build, and declaring them - # there would manufacture unavailable checks for a question the event - # never asked. That event policy is the `if:` below, and it is the only - # place it is stated. - name: 'libpvxs vs accepted-main' id: main-core if: ${{ always() && steps.snapshots.outcome == 'success' && github.event_name == 'pull_request' }} @@ -497,23 +492,52 @@ jobs: severity-preset: default # Each id below is check-target's OWN canonical check-id output. This - # step selects; it never constructs a target@profile#channel@depth - # string and never reconstructs the expected set. A step the event did - # not ask for contributes no id and is not declared; a step that ran but - # produced no report is declared with an empty report, so it aggregates - # as unavailable rather than vanishing. + # step is the single declaration of what the run was SUPPOSED to + # produce, and it is deliberately independent of whether any comparison + # ran. Deriving the ids from check-target's own check-id outputs looks + # tidier but loses the expected set in exactly the case that matters: if + # the candidate download or snapshot resolution fails, every comparison + # step is skipped, emits no id, and the run would then declare -- and + # report -- a shorter set of checks than it actually had. + # + # An entry whose `report` is empty is a declared check that produced + # nothing, which aggregates as unavailable rather than vanishing. + # Nothing here classifies a result; the ids are cross-checked upstream, + # where a report recording its own target_id is authoritative and a + # declaration disagreeing with it is refused rather than relabelled. - name: Declare the checks this event asked for id: declare if: ${{ always() }} env: - DECLARED: | - [{"id": "${{ steps.main-core.outputs.check-id }}", "report": "${{ steps.main-core.outputs.report-path }}"}, - {"id": "${{ steps.main-ioc.outputs.check-id }}", "report": "${{ steps.main-ioc.outputs.report-path }}"}, - {"id": "${{ steps.rel-core.outputs.check-id }}", "report": "${{ steps.rel-core.outputs.report-path }}"}, - {"id": "${{ steps.rel-ioc.outputs.check-id }}", "report": "${{ steps.rel-ioc.outputs.report-path }}"}] + # The accepted-main checks exist only on a pull request: there is no + # PR base on a push or tag build, and declaring them there would + # manufacture a missing check for a question nobody asked. + PR_EVENT: ${{ github.event_name == 'pull_request' }} + MAIN_CORE: ${{ steps.main-core.outputs.report-path }} + MAIN_IOC: ${{ steps.main-ioc.outputs.report-path }} + REL_CORE: ${{ steps.rel-core.outputs.report-path }} + REL_IOC: ${{ steps.rel-ioc.outputs.report-path }} run: | set -eu - python3 -c 'import json,os; print("checks=" + json.dumps([c for c in json.loads(os.environ["DECLARED"]) if c["id"]]))' >> "$GITHUB_OUTPUT" + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os + p = os.environ["ABICHECK_PROFILE"] + checks = [] + if os.environ["PR_EVENT"] == "true": + checks += [ + {"id": f"libpvxs@{p}#accepted-main@headers", + "report": os.environ["MAIN_CORE"]}, + {"id": f"libpvxsIoc@{p}#accepted-main@headers", + "report": os.environ["MAIN_IOC"]}, + ] + checks += [ + {"id": f"libpvxs@{p}#release-contract@headers~release", + "report": os.environ["REL_CORE"]}, + {"id": f"libpvxsIoc@{p}#release-contract@headers~release", + "report": os.environ["REL_IOC"]}, + ] + print("checks=" + json.dumps(checks)) + PY # One aggregate document over every declared check. The publisher # renders exactly this; it never re-runs a comparison and never diff --git a/documentation/abicheck.md b/documentation/abicheck.md index b43152aa8..7add02d07 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -158,9 +158,11 @@ defect, not change — and part of why this stays advisory. The analysis is unprivileged and cannot comment. `abicheck-report.yml` is a separate trusted `workflow_run` publisher built from two reviewed Actions: -`verify-source-run` establishes which run, which pull request and which -commit was actually analysed — from the API, never from the artifact — and -`report` renders the document as one sticky comment per PR and profile, +`verify-source-run` establishes the producer run and the pull request it +belongs to through the API, reads the commit actually analysed out of +`aggregate.json`, and then verifies that commit's association with the pull +request through the API; `report` renders the document as one sticky comment +per PR and profile, covering both components with the baseline channels separately labelled. The artifact is acquired **once**: `provenance-from` reads the producer's From 7cbd1c377e7565d9dee87b82be4dc9972a45debb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:56:42 +0000 Subject: [PATCH 43/44] ci: stop pre-creating the baseline channel directories The first real run of the consolidated job failed on both accepted-main checks: resolve-baseline failed (ambiguous): .../abicheck-baseline-main exists but does not contain a manifest.json -- this looks like an empty/partial cache restore or stripped artifact directory, not simply an unpublished baseline (a baseline-path that does not exist at all is the not_found/bootstrap case). The `mkdir -p` step defeated its own purpose. It was added so a staging failure would reach resolve-baseline as that channel's own `not_found` outcome, but resolve-baseline deliberately separates the two states: a path that does not exist is `not_found`, the bootstrap case these checks opt into with baseline-required: false, while a path that exists without a manifest.json is `ambiguous` -- a hard failure, because that is what a partial cache restore or a stripped artifact looks like. Pre-creating the directory therefore converted every unpublished baseline into a corrupt-evidence error. Both download commands create their own --dir, so nothing else needed it. The same run already demonstrates the intended behaviour on the release channel, whose baseline-path is a file inside the directory and so did not exist: outcome not_found, verdict NO_BASELINE, step success, the check still declared and reported as unavailable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q2Herd15iLCwK3ainaw52a --- .github/workflows/ci-scripts-build.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 75c887e2c..44f8b2f8a 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -325,13 +325,16 @@ jobs: name: abicheck-candidate-${{ env.ABICHECK_PROFILE }} path: ${{ env.CANDIDATE_DIR }} - # Both channel directories always exist, so a staging failure reaches - # resolve-baseline as its own `not_found` outcome on a declared check - # rather than as a skipped step that would vanish from the expected set. - - name: Prepare the baseline channel directories - run: mkdir -p "$BASELINE_MAIN_DIR" "$BASELINE_RELEASE_DIR" - # ── Baseline staging ───────────────────────────────────────────── + # Neither channel directory is pre-created. resolve-baseline treats a + # baseline-path that does not exist as `not_found` -- the bootstrap case + # these checks opt into with baseline-required: false -- but a directory + # that exists WITHOUT a manifest.json as `ambiguous`, a hard failure, + # because that is what a partial cache restore or a stripped artifact + # looks like. So `mkdir -p` here would turn every unpublished baseline + # into a corrupt-evidence error. The download commands create their own + # directories. + # # Two distinct questions, two distinct channels, never merged: # accepted-main what did THIS pull request introduce? # release-contract what changed since the selected supported release? From d4023bf4cda2142e8daceff2ecaaf07d3bc96be5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:24:05 +0000 Subject: [PATCH 44/44] ci: repin abicheck to current main and drop default-restating inputs Audited the integration against abicheck main 902ee99 and against that revision's own set-up-abi-compatibility-ci skill. Repin: all 16 Action/workflow references move from e38c3f9 to 902ee99. Verified behaviour-neutral for this consumer -- the single intervening commit removes the ABICC compat front end (which this integration never used) and strips ADR cross-references from prose; the only interface additions are optional recording inputs we do not pass (baseline's extraction-context, check-target's build-system / build-generator), and publish-baseline's new project-config / toolchain-bindings-path apply to its capture mode, while this project publishes through its existing-set mode. Leanness: the skill's rule is never to restate a default, so `severity-preset: default` and `head-sha: ${{ github.sha }}` are dropped from all four check-target calls -- both are exactly those inputs' upstream defaults. Eight lines, no behaviour change. `base-ref` stays on the accepted-main pair, where the default is empty and the value is real. Documented two non-obvious invariants: - the pin is a main revision rather than the v0.6.0 release tag because aggregate, report, verify-baseline-source and verify-source-run do not exist in v0.6.0, and that tag is not an ancestor of main; - the repeated literal SHA in each `uses:` is required (GitHub does not expand expressions there), so ABICHECK_REF cannot replace it; - runner-image toolchain drift needs no guard here: a mismatched pair resolves as profile_mismatch with verdict null and exit 16, which is explicitly not a pass. Re-confirmed unchanged and not worked around: the actions/report blocker is still present on main at 902ee99 (expressions in two input descriptions), and check-project.yml still consumes candidate binaries plus build-output.json, so adopting it would re-extract per cell instead of reusing the single capture. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q2Herd15iLCwK3ainaw52a --- .github/actions/abicheck-capture/action.yml | 2 +- .github/workflows/abicheck-baseline.yml | 10 ++++----- .github/workflows/abicheck-report.yml | 4 ++-- .github/workflows/ci-scripts-build.yml | 24 +++++++-------------- documentation/abicheck.md | 15 +++++++++++-- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.github/actions/abicheck-capture/action.yml b/.github/actions/abicheck-capture/action.yml index d46991199..ccd390707 100644 --- a/.github/actions/abicheck-capture/action.yml +++ b/.github/actions/abicheck-capture/action.yml @@ -120,7 +120,7 @@ runs: - name: Capture ABI snapshots (libpvxs, libpvxsIoc) id: capture - uses: abicheck/abicheck/actions/baseline@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/baseline@902ee996795dbe529a5d6d348a220864634c0e2b with: library-spec: ${{ steps.epics.outputs.spec }} # Binding is abicheck's: it walks the document as JSON and inserts diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml index 676498495..a6437c383 100644 --- a/.github/workflows/abicheck-baseline.yml +++ b/.github/workflows/abicheck-baseline.yml @@ -38,7 +38,7 @@ permissions: env: ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent - ABICHECK_REF: e38c3f91b8f956f7c8fa51b60d17fc4030969dec + ABICHECK_REF: 902ee996795dbe529a5d6d348a220864634c0e2b jobs: # ── Automatic: publish the tag build's own capture ───────────────────── @@ -62,7 +62,7 @@ jobs: eligible: ${{ steps.tag.outputs.eligible }} steps: - id: tag - uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/verify-baseline-source@902ee996795dbe529a5d6d348a220864634c0e2b with: mode: tag tag: ${{ github.event.workflow_run.head_branch }} @@ -81,7 +81,7 @@ jobs: # the producer run this publication was triggered by. actions: read contents: write - uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@902ee996795dbe529a5d6d348a220864634c0e2b with: # Publish the finished set the producer captured; no dump, no build # query, no compiler, no comparison happens in this workflow. @@ -128,7 +128,7 @@ jobs: - name: Resolve the requested tag id: tag - uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/verify-baseline-source@902ee996795dbe529a5d6d348a220864634c0e2b with: mode: tag tag: ${{ inputs.tag }} @@ -185,7 +185,7 @@ jobs: permissions: actions: read contents: write - uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/.github/workflows/publish-baseline.yml@902ee996795dbe529a5d6d348a220864634c0e2b with: # Same-run handoff: the artifact was uploaded by bootstrap-build, in # this run, so no source-run verification is needed or possible. diff --git a/.github/workflows/abicheck-report.yml b/.github/workflows/abicheck-report.yml index b68c02234..d713cefa5 100644 --- a/.github/workflows/abicheck-report.yml +++ b/.github/workflows/abicheck-report.yml @@ -71,7 +71,7 @@ jobs: # compatibility outcome from the run's conclusion. - name: Verify the producer run and acquire its report id: source - uses: abicheck/abicheck/actions/verify-source-run@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/verify-source-run@902ee996795dbe529a5d6d348a220864634c0e2b with: source-run-id: ${{ github.event.workflow_run.id }} expect-repository: ${{ github.repository }} @@ -114,7 +114,7 @@ jobs: - name: Publish the ABI/API report id: publish - uses: abicheck/abicheck/actions/report@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/report@902ee996795dbe529a5d6d348a220864634c0e2b with: report: ${{ steps.source.outputs.report-path }} repository: ${{ github.repository }} diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index 44f8b2f8a..8288f2f90 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -36,7 +36,7 @@ env: # libevent rather than the system one. ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent # One immutable, merged abicheck revision for every Action reference. - ABICHECK_REF: e38c3f91b8f956f7c8fa51b60d17fc4030969dec + ABICHECK_REF: 902ee996795dbe529a5d6d348a220864634c0e2b jobs: native: @@ -372,7 +372,7 @@ jobs: if: ${{ steps.candidate.outcome == 'success' && github.event_name == 'pull_request' }} id: base-source continue-on-error: true - uses: abicheck/abicheck/actions/verify-baseline-source@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/verify-baseline-source@902ee996795dbe529a5d6d348a220864634c0e2b with: mode: producer-run workflow: .github/workflows/ci-scripts-build.yml @@ -406,7 +406,7 @@ jobs: - name: Locate candidate snapshots id: snapshots if: ${{ steps.candidate.outcome == 'success' }} - uses: abicheck/abicheck/actions/resolve-baseline@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/resolve-baseline@902ee996795dbe529a5d6d348a220864634c0e2b with: kind: members baseline-path: ${{ env.CANDIDATE_DIR }} @@ -419,7 +419,7 @@ jobs: - name: 'libpvxs vs accepted-main' id: main-core if: ${{ always() && steps.snapshots.outcome == 'success' && github.event_name == 'pull_request' }} - uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -432,14 +432,12 @@ jobs: gate-mode: advisory new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} build-config: .ci-local/abicheck.yml - head-sha: ${{ github.sha }} base-ref: ${{ github.event.pull_request.base.ref }} - severity-preset: default - name: 'libpvxsIoc vs accepted-main' id: main-ioc if: ${{ always() && steps.snapshots.outcome == 'success' && github.event_name == 'pull_request' }} - uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -452,14 +450,12 @@ jobs: gate-mode: advisory new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} build-config: .ci-local/abicheck.yml - head-sha: ${{ github.sha }} base-ref: ${{ github.event.pull_request.base.ref }} - severity-preset: default - name: 'libpvxs vs release-contract' id: rel-core if: ${{ always() && steps.snapshots.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b with: name: libpvxs profile: ${{ env.ABICHECK_PROFILE }} @@ -472,13 +468,11 @@ jobs: explicit-id: release new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} build-config: .ci-local/abicheck.yml - head-sha: ${{ github.sha }} - severity-preset: default - name: 'libpvxsIoc vs release-contract' id: rel-ioc if: ${{ always() && steps.snapshots.outcome == 'success' }} - uses: abicheck/abicheck/actions/check-target@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b with: name: libpvxsIoc profile: ${{ env.ABICHECK_PROFILE }} @@ -491,8 +485,6 @@ jobs: explicit-id: release new-library: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} build-config: .ci-local/abicheck.yml - head-sha: ${{ github.sha }} - severity-preset: default # Each id below is check-target's OWN canonical check-id output. This # step is the single declaration of what the run was SUPPOSED to @@ -554,7 +546,7 @@ jobs: - name: Aggregate both components id: aggregate if: ${{ always() }} - uses: abicheck/abicheck/actions/aggregate@e38c3f91b8f956f7c8fa51b60d17fc4030969dec + uses: abicheck/abicheck/actions/aggregate@902ee996795dbe529a5d6d348a220864634c0e2b with: reports-dir: ${{ env.REPORT_DIR }} manifest-path: ${{ env.EXPECTED_TARGETS }} diff --git a/documentation/abicheck.md b/documentation/abicheck.md index 7add02d07..e9a1a5820 100644 --- a/documentation/abicheck.md +++ b/documentation/abicheck.md @@ -15,8 +15,14 @@ a pinned abicheck Action. | `.ci-local/abicheck.yml` | how the public headers are parsed for extraction | | `.github/actions/abicheck-capture/action.yml` | EPICS build-context resolution, then one `uses:` | -Every abicheck Action is pinned to one merged, immutable revision, named once -per workflow as `ABICHECK_REF`. +Every abicheck Action is pinned to one merged, immutable revision. The SHA is +repeated in each `uses:` because GitHub does not expand expressions there; +`ABICHECK_REF` carries the same value to the inputs that record it. + +The pin is a `main` revision, not the `v0.6.0` release tag, deliberately: +`aggregate`, `report`, `verify-baseline-source` and `verify-source-run` do not +exist in `v0.6.0`, and that tag is not an ancestor of `main`. Repin to a +release once those four ship in one. ## Selected surface and profile @@ -208,6 +214,11 @@ execution boundary. through `-I` roots are still attributed to `libpvxs`, and `pvxs::version_*` to `libpvxsIoc`. Both fold to zero gating findings and are labelled pre-existing on both sides. No suppressions are added here. +- **Toolchain drift is upstream's to catch, not ours.** The profile name does + not pin the runner image, so a runner upgrade can change the compiler under + a published baseline. That needs no guard here: a mismatched pair resolves + as `profile_mismatch` with `verdict: null` and exit 16, which is explicitly + not a pass. - **No published baselines yet.** The fork has tags but no GitHub Releases carrying the asset, and `master` does not yet contain the integration, so no push run of a base commit has produced a candidate artifact. Both are