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.yml b/.ci-local/abicheck.yml new file mode 100644 index 000000000..775cdd7bd --- /dev/null +++ b/.ci-local/abicheck.yml @@ -0,0 +1,25 @@ +# Extraction context for the advisory abicheck shadow check. +# +# 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: + # 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: 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 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 new file mode 100644 index 000000000..ccd390707 --- /dev/null +++ b/.github/actions/abicheck-capture/action.yml @@ -0,0 +1,143 @@ +name: 'PVXS ABI capture' +description: >- + 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. 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. + + 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 points this at its own checkout. + 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' + 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 + +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 }} + 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 EPICS build context + id: epics + shell: bash + env: + TOP: ${{ inputs.top || github.workspace }} + SPEC_IN: ${{ inputs.component-spec }} + CFG_IN: ${{ inputs.build-config }} + WORKSPACE: ${{ github.workspace }} + run: | + set -eu + + # 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" + + # 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 + # This capture is declared for a native Linux install layout only. + case "$EPICS_HOST_ARCH" in + linux-*) ;; + *) echo "::error::expected a native Linux host arch, got '${EPICS_HOST_ARCH:-}'" + exit 64 ;; + esac + + { + 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@902ee996795dbe529a5d6d348a220864634c0e2b + 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 }} + profile: ${{ inputs.profile }} + depth: headers + 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). + # The decoded content is identical either way. + snapshot-compression: zstd diff --git a/.github/workflows/abicheck-baseline.yml b/.github/workflows/abicheck-baseline.yml new file mode 100644 index 000000000..a6437c383 --- /dev/null +++ b/.github/workflows/abicheck-baseline.yml @@ -0,0 +1,196 @@ +# Publish the release-contract ABI baseline-set for the selected profile. +# +# 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. 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, 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. + +name: ABI baseline + +on: + workflow_run: + workflows: ["PVXS EPICS"] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build, capture and publish a baseline-set for.' + required: true + type: string + +permissions: + contents: read + +env: + ABICHECK_PROFILE: linux-x86_64-gcc-default-base7.0-bundled-libevent + ABICHECK_REF: 902ee996795dbe529a5d6d348a220864634c0e2b + +jobs: + # ── 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: 10 + permissions: + contents: read + outputs: + eligible: ${{ steps.tag.outputs.eligible }} + steps: + - id: tag + uses: abicheck/abicheck/actions/verify-baseline-source@902ee996795dbe529a5d6d348a220864634c0e2b + with: + mode: tag + tag: ${{ github.event.workflow_run.head_branch }} + built-sha: ${{ github.event.workflow_run.head_sha }} + - if: ${{ steps.tag.outputs.eligible != 'true' }} + env: + OUTCOME: ${{ steps.tag.outputs.outcome }} + run: | + echo "nothing to publish from this run; tag outcome: $OUTCOME" + + 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@902ee996795dbe529a5d6d348a220864634c0e2b + 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 ─────────────────── + # + # 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 + steps: + # 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: Resolve the requested tag + id: tag + uses: abicheck/abicheck/actions/verify-baseline-source@902ee996795dbe529a5d6d348a220864634c0e2b + with: + mode: tag + tag: ${{ inputs.tag }} + + - name: Check out the historical revision + uses: actions/checkout@v6 + with: + ref: ${{ steps.tag.outputs.commit-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 + + # PVXS's normal build commands, the same ones the CI matrix runs. + - 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 + + # Same declaration and same shared action as the matrix leg, so the + # bootstrap cannot drift from normal capture. + - name: Capture the historical revision + uses: ./.github/actions/abicheck-capture + with: + top: ${{ github.workspace }}/historical + output-dir: ${{ runner.temp }}/baseline-set + 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: + name: abicheck-bootstrap-${{ env.ABICHECK_PROFILE }} + path: ${{ runner.temp }}/baseline-set + if-no-files-found: error + retention-days: 1 + + bootstrap-publish: + needs: bootstrap-build + # 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 + 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. + 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 new file mode 100644 index 000000000..d713cefa5 --- /dev/null +++ b/.github/workflows/abicheck-report.yml @@ -0,0 +1,169 @@ +# 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 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 -- 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. +# 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 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. +# +# KNOWN BLOCKER: at the pinned revision actions/report cannot be loaded at +# all; see documentation/abicheck.md ("Current limitations"). + +name: ABI/API report + +on: + workflow_run: + workflows: ["PVXS EPICS"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +concurrency: + # 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 + cancel-in-progress: false + +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). + 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: + # 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, 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@902ee996795dbe529a5d6d348a220864634c0e2b + 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 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: $REFUSAL" + exit 1 + + # 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::the producer run produced no aggregate document; nothing is published, and this is not a clean result" + exit 1 + + - name: Publish the ABI/API report + id: publish + uses: abicheck/abicheck/actions/report@902ee996795dbe529a5d6d348a220864634c0e2b + with: + report: ${{ steps.source.outputs.report-path }} + repository: ${{ github.repository }} + pr-number: ${{ steps.source.outputs.pr-number }} + # 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. 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 }} + # 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 }} + (attempt ${{ github.event.workflow_run.run_attempt }}) + report-url: ${{ github.event.workflow_run.html_url }} + 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. + - 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=$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 beefedbd4..8288f2f90 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -17,11 +17,26 @@ 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 + # One immutable, merged abicheck revision for every Action reference. + ABICHECK_REF: 902ee996795dbe529a5d6d348a220864634c0e2b jobs: native: @@ -49,6 +64,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 +188,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 +238,368 @@ jobs: path: '**/O.*/*.tap' if-no-files-found: ignore + # ── ABI/API evidence capture (advisory; ABICC stays authoritative) ──── + # + # 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 produced an installation to look at. + # + # 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. + project-ref: ${{ github.sha }} + profile: ${{ env.ABICHECK_PROFILE }} + abicheck-ref: ${{ env.ABICHECK_REF }} + + - 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: Note a failed ABI capture + if: ${{ always() && matrix.abicheck && steps.build-main.outcome == 'success' && steps.abi-capture.outcome != 'success' }} + run: | + 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) ──────────── + # + # 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 never + # suppresses an available ABI finding. + abicheck: + name: ABI/API check (advisory) + needs: native + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + # 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: 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 + BASELINE_MAIN_DIR: ${{ github.workspace }}/abicheck-baseline-main + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Download candidate snapshots + id: candidate + # 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 }} + + # ── 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? + # + # 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 + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + 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 + # 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" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + 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. + - 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@902ee996795dbe529a5d6d348a220864634c0e2b + 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 }} + RUN_ID: ${{ steps.base-source.outputs.run-id }} + run: | + set -eu + 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)" + exit 1 + fi + + # ── Comparison ─────────────────────────────────────────────── + # 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 + # CANDIDATE side, not only the baseline side. + - name: Locate candidate snapshots + id: snapshots + if: ${{ steps.candidate.outcome == 'success' }} + uses: abicheck/abicheck/actions/resolve-baseline@902ee996795dbe529a5d6d348a220864634c0e2b + 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' && github.event_name == 'pull_request' }} + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b + 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: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} + build-config: .ci-local/abicheck.yml + base-ref: ${{ github.event.pull_request.base.ref }} + + - 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@902ee996795dbe529a5d6d348a220864634c0e2b + 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: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} + build-config: .ci-local/abicheck.yml + base-ref: ${{ github.event.pull_request.base.ref }} + + - name: 'libpvxs vs release-contract' + id: rel-core + if: ${{ always() && steps.snapshots.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b + 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: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxs }} + build-config: .ci-local/abicheck.yml + + - name: 'libpvxsIoc vs release-contract' + id: rel-ioc + if: ${{ always() && steps.snapshots.outcome == 'success' }} + uses: abicheck/abicheck/actions/check-target@902ee996795dbe529a5d6d348a220864634c0e2b + 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: ${{ fromJSON(steps.snapshots.outputs.snapshot-paths).libpvxsIoc }} + build-config: .ci-local/abicheck.yml + + # 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 + # 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: + # 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 - <<'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 + # 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 + # refused declaration, a usage error, or a document that does not + # 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@902ee996795dbe529a5d6d348a220864634c0e2b + with: + reports-dir: ${{ env.REPORT_DIR }} + manifest-path: ${{ env.EXPECTED_TARGETS }} + checks: ${{ steps.declare.outputs.checks }} + # 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 + { + echo "## ABI/API check (advisory)" + echo + 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" + + - 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..e9a1a5820 --- /dev/null +++ b/documentation/abicheck.md @@ -0,0 +1,228 @@ +# Advisory ABI/API check (abicheck) + +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. + +## Setup + +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:` | + +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 + +`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. + +Two assertions in the declaration are deliberate, because a glob alone would +lose them and a pattern matching nothing is a hard error upstream: + +- `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. + +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. + +## Build reuse + +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. + +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. + +## Evidence depth + +`--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 | +|---|---|---| +| 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 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 +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. +- **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 + reported as missing coverage rather than worked around. + +Measurements, comparison transcripts and the upstream issue references live +in the pull request discussion, not here.