Skip to content

deps(deps): Bump ruff from 0.15.15 to 0.15.16 #1749

deps(deps): Bump ruff from 0.15.15 to 0.15.16

deps(deps): Bump ruff from 0.15.15 to 0.15.16 #1749

Workflow file for this run

name: CI - Static Analysis (C Code)
on:
push:
branches: [ main, develop, 'feature/**', 'fix/**' ]
pull_request:
branches: [ main, develop ]
schedule:
# Nightly extended sanitizer matrix at 04:00 UTC (audit Issue 9
# close-out, promoted from weekly to nightly). MSan and TSan have
# higher false-positive rates than ASan+UBSan and a per-PR latency
# cost we're not willing to pay, so we run them out of the
# merge-critical path. Nightly cadence shortens the regression
# window from up-to-7-days to up-to-24-hours — the operational
# cost is one extra runner-hour per night for the four scheduled
# jobs (MSan, TSan, Valgrind, reproducible-build), which is in
# the noise compared with the bounded-bisect benefit when a real
# sanitiser regression does land.
- cron: '0 4 * * *'
# workflow_dispatch enables on-demand runs of the extended matrix —
# several jobs below predicate on `github.event_name ==
# 'workflow_dispatch'`, so the trigger MUST be declared here or the
# advertised manual escape hatch is impossible to fire
# (Copilot review #322).
workflow_dispatch:
permissions:
contents: read
security-events: write # Required for CodeQL SARIF upload
# Collapse overlapping runs on the same ref; main and scheduled runs
# (the weekly extended sanitizer matrix) are preserved.
concurrency:
group: static-analysis-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'schedule' }}
jobs:
cppcheck:
name: Cppcheck Static Analysis
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install cppcheck
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cppcheck
- name: Run cppcheck on C sources
run: |
cppcheck \
--enable=warning,style,performance,portability \
--suppress=missingIncludeSystem \
--suppress=unusedFunction \
--error-exitcode=1 \
--inline-suppr \
--std=c11 \
-I include/ \
-DAMA_USE_NATIVE_PQC \
--force \
-i src/c/vendor/ \
src/c/ 2>&1 | tee cppcheck-report.txt
- name: Upload cppcheck report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cppcheck-report
path: cppcheck-report.txt
retention-days: 30
clang-analyzer:
name: Clang Static Analyzer (scan-build)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang clang-tools
- name: Run scan-build
run: |
mkdir -p build-scan
cd build-scan
scan-build --status-bugs \
-enable-checker security.FloatLoopCounter \
-enable-checker security.insecureAPI.UncheckedReturn \
-enable-checker alpha.security.ArrayBoundV2 \
-enable-checker alpha.security.MallocOverflow \
-enable-checker alpha.security.ReturnPtrRange \
-enable-checker alpha.security.taint.TaintPropagation \
cmake .. \
-DCMAKE_C_COMPILER=clang \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=OFF \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF
scan-build --status-bugs \
-enable-checker security.FloatLoopCounter \
-enable-checker security.insecureAPI.UncheckedReturn \
-enable-checker alpha.security.ArrayBoundV2 \
-enable-checker alpha.security.MallocOverflow \
-enable-checker alpha.security.ReturnPtrRange \
-enable-checker alpha.security.taint.TaintPropagation \
make -j$(nproc)
- name: Upload scan-build report
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scan-build-report
path: /tmp/scan-build-*
retention-days: 30
codeql:
name: CodeQL Security Analysis
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: c-cpp, python
queries: security-and-quality
config-file: ./.github/codeql/codeql-config.yml
- name: Build C library for CodeQL
run: |
cmake -B build \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=OFF \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF
cmake --build build -j$(nproc)
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:c-cpp"
compiler-warnings:
name: Strict Compiler Warnings
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
compiler: [gcc, clang]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake ${{ matrix.compiler }}
- name: Build with strict warnings (Werror)
run: |
cmake -B build-strict \
-DCMAKE_C_COMPILER=${{ matrix.compiler }} \
-DCMAKE_C_FLAGS="-Wall -Wextra -Wpedantic -Wshadow -Wformat=2 -Wconversion -Wno-sign-conversion" \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF
cmake --build build-strict -j$(nproc)
- name: Run tests
run: cd build-strict && ctest --output-on-failure
version-consistency:
# Enforces audit 5a: every file that declares the library version must
# agree with ama_cryptography/__init__.py. Also enforces audit 6a:
# root INVARIANTS.md is canonical, while .github/INVARIANTS.md must
# remain a short pointer so a second divergent copy cannot reappear.
name: Version / Invariants Consistency
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run version consistency check
run: python3 tools/check_version_consistency.py
address-sanitizer:
name: AddressSanitizer + UBSan
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang
- name: Build with ASan + UBSan
run: |
cmake -B build-asan \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF
cmake --build build-asan -j$(nproc)
- name: Run tests under sanitizers
env:
ASAN_OPTIONS: detect_leaks=1:detect_stack_use_after_return=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: cd build-asan && ctest --output-on-failure
# =========================================================================
# Extended sanitizer + clang-tidy + reproducible-build matrix
# (audit Issues 9 + 10) — runs weekly on the schedule above plus on
# manual workflow_dispatch. PR-time and push-time runs only invoke
# the original ASan+UBSan gate above; this matrix is intentionally
# out of the merge critical path because MSan/TSan/clang-tidy/
# reproducible-build can each take 15-25 minutes by themselves.
# =========================================================================
memory-sanitizer:
name: MemorySanitizer (uninit reads)
# MSan needs every linked library — including libc++ / libstdc++ —
# to be MSan-instrumented or the false-positive volume becomes
# unmanageable. We use clang's `-stdlib=libc++` with the
# pre-instrumented runtime that ships with clang-18. This is a
# known cost of MSan that the project accepts because the
# uninitialized-read class is invisible to ASan and the
# constant-time codebase has hot spots (lookup masks, conditional
# selects) that an uninit-read can mask without symptom.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang-18 libc++-18-dev libc++abi-18-dev
- name: Build with MSan
run: |
cmake -B build-msan \
-DCMAKE_C_COMPILER=clang-18 \
-DCMAKE_CXX_COMPILER=clang++-18 \
-DCMAKE_C_FLAGS="-fsanitize=memory -fsanitize-memory-track-origins=2 -fno-omit-frame-pointer -g -O1" \
-DCMAKE_CXX_FLAGS="-fsanitize=memory -fsanitize-memory-track-origins=2 -fno-omit-frame-pointer -g -O1 -stdlib=libc++" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=memory -stdlib=libc++" \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF \
-DAMA_ENABLE_SIMD=OFF \
-DAMA_AES_CONSTTIME=ON
cmake --build build-msan -j$(nproc)
- name: Run tests under MSan
env:
MSAN_OPTIONS: print_stacktrace=1:halt_on_error=1:abort_on_error=1
# No `-L fast` label exists in tests/c/CMakeLists.txt today
# (Copilot review #322). CTest exits successfully when zero
# tests match a label, so the previous `ctest -L fast ||
# fallback` construction would have passed without running
# anything. Run the full suite directly until a `fast` label
# taxonomy is introduced. Failures surface in the usual way.
run: cd build-msan && ctest --output-on-failure
thread-sanitizer:
name: ThreadSanitizer (dispatch init races)
# TSan targets the multi-threaded code in ama_cpuid.c +
# ama_dispatch.c — the platform once-primitive (pthread_once on
# POSIX, InitOnceExecuteOnce on Windows) per INVARIANT-15. A
# data race on the dispatch table would cause SIMD kernels to be
# called with NULL function pointers from one thread while another
# is still initialising; TSan is the only sanitiser that detects
# this class.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang
- name: Build with TSan
run: |
cmake -B build-tsan \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_C_FLAGS="-fsanitize=thread -fno-omit-frame-pointer -g -O1" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread" \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF \
-DAMA_AES_CONSTTIME=ON
cmake --build build-tsan -j$(nproc)
- name: Run tests under TSan
env:
TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1
run: cd build-tsan && ctest --output-on-failure
valgrind-memcheck:
name: Valgrind memcheck (defense-in-depth)
# Complements ASan with a different leak/uninit reader. Slow —
# ~5x runtime — so scheduled only. The test suite that runs here
# is the same one ctest runs in the ASan job; Valgrind's strength
# is that it catches uninit reads that ASan misses (different
# implementation strategy) and gives us a second opinion on the
# ASan reports for free.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang valgrind
- name: Build (no sanitizers, just symbols + no LTO)
run: |
cmake -B build-valgrind \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_C_FLAGS="-g -O1 -fno-omit-frame-pointer" \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF \
-DAMA_AES_CONSTTIME=ON \
-DAMA_ENABLE_SIMD=OFF
cmake --build build-valgrind -j$(nproc)
- name: Run a focused subset under Valgrind memcheck
run: |
cd build-valgrind
# Run the most coverage-rich subset under Valgrind — the
# PQC primitives and AEAD tests exercise the largest stack
# / heap surface. Running the full ctest suite under
# Valgrind would exceed the runner timeout.
for test in bin/test_consttime bin/test_core bin/test_aes_gcm_scalar_kat bin/test_chacha20poly1305 bin/test_ed25519 bin/test_sha3; do
if [ -x "$test" ]; then
echo "=== valgrind: $test ==="
valgrind \
--error-exitcode=1 \
--leak-check=full \
--show-leak-kinds=definite,indirect \
--errors-for-leak-kinds=definite \
--track-origins=yes \
"$test"
fi
done
clang-tidy:
name: clang-tidy (security + correctness, fail-closed)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
# FAIL-CLOSED (audit Issue 9 close-out). The codebase is
# clang-tidy-clean against the enabled check inventory in
# `.clang-tidy`; `WarningsAsErrors: '*'` there flips every finding
# to a hard error. A new finding fails this job, which fails the
# workflow, which fails the PR. See `.clang-tidy` header for the
# three checks dropped from the enabled list with rationale (each
# incompatible with the project's cryptographic-C style — dropped
# explicitly, not silenced per-site). The previous advisory
# posture (continue-on-error + trailing exit 0) was removed in
# this commit.
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update
sudo apt-get install -y cmake clang clang-tidy
- name: Configure and generate compile_commands.json
run: |
cmake -B build-tidy \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DAMA_USE_NATIVE_PQC=ON \
-DAMA_BUILD_TESTS=OFF \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_LTO=OFF \
-DAMA_AES_CONSTTIME=ON
- name: Run clang-tidy on our C sources (fail-closed)
# Only run on src/c/ and include/, excluding vendored code under
# src/c/vendor/. The .clang-tidy config file at repo root
# drives check selection AND the WarningsAsErrors flip; CI
# just enumerates the input files and propagates the exit
# status. Output is captured to an artefact regardless of
# status so reviewers can scan findings without re-running
# locally.
#
# FAIL-CLOSED semantics (audit Issue 9 close-out): no
# `continue-on-error`, no `set +e`, no `exit 0`. `clang-tidy`
# exits non-zero when it emits any error (which it now does
# for every enabled check, per `.clang-tidy::WarningsAsErrors`),
# and that exit status fails this step.
#
# `set -o pipefail` IS load-bearing here (PR #326 follow-up):
# without it the inner `if ! clang-tidy ... | tee ...` only
# sees `tee`'s exit code (always 0), so any clang-tidy error
# is silently swallowed and `rc` never flips off 0 — which
# is how the pre-existing `cert-err34-c` findings in
# `dispatch_cache_load()` (atoi() calls introduced in
# `58e7a2d`) were passing CI even under this section's
# "FAIL-CLOSED" claim. Now genuinely fail-closed.
shell: bash
run: |
set -o pipefail
files=$(find src/c include \
-type d -name vendor -prune -o \
-type f \( -name '*.c' -o -name '*.h' \) -print)
if [ -z "$files" ]; then
echo "ERROR: no C/H source files found — is the layout intact?"
exit 1
fi
# `--quiet` suppresses the "X warnings generated" per-file
# banner; diagnostics still print. Serial run so output is
# ordered; tidy itself is single-threaded per TU.
: > clang-tidy-findings.txt
rc=0
while IFS= read -r f; do
if ! clang-tidy -p build-tidy --quiet "$f" 2>&1 | tee -a clang-tidy-findings.txt; then
rc=1
fi
done <<< "$files"
if [ "$rc" -ne 0 ]; then
echo "::error::clang-tidy found errors — see uploaded clang-tidy-findings artefact."
fi
exit "$rc"
- name: Upload clang-tidy findings
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: clang-tidy-findings
path: clang-tidy-findings.txt
retention-days: 30
reproducible-build:
name: Reproducible Build (digest + .py + native artefact byte-equality)
# INVARIANT-8 declares deterministic builds. Audit Issue 10
# close-out: in addition to the STRICT digest + .py check that
# PR #322 introduced, this job now ALSO strictly diffs the
# compiled native artefacts (libama_cryptography.so / Cython
# extension .so) between the two passes.
#
# SCOPE OF THIS JOB:
#
# STRICT — INTEGRITY_DIGEST_HEX in _integrity_signature.py must
# match across both builds. Artefact whose stability
# the import-time integrity gate depends on.
# STRICT — Every .py file inside the wheel must be byte-identical
# across both builds (excluding the per-build ephemeral
# `_integrity_signature.py` — INVARIANT-17 keeps that
# file non-byte-stable; the digest check above
# validates the OTHER .py files that the digest is
# computed over).
# STRICT — Native artefacts (.so / .pyd / Cython-built .so)
# must be byte-identical across both builds.
# Promoted from ADVISORY in the audit Issue 10
# close-out. Achieved by:
# 1. Pinned manylinux_2_28 container — locks the
# gcc / glibc / binutils / cmake versions so the
# host toolchain cannot drift between two CI runs.
# 2. -fdebug-prefix-map=$PWD=. strips host paths
# from DWARF debug info (otherwise the path
# difference between two checkout dirs leaks into
# the .so).
# 3. -Wl,--build-id=sha1 forces the linker's build-id
# to a content-derived hash instead of the default
# "random" mode that picks a fresh build-id per
# invocation.
# 4. AR_FLAGS=Drcs forces archive members to be
# emitted with the deterministic flag (no
# timestamps / UID / GID); harmless on modern
# binutils where it's already the default, but
# defends against an older ar on a future runner
# image.
#
# The signature artefact `_integrity_signature.py` legitimately
# differs between builds (INVARIANT-17 ephemeral per-build keypair).
# The wheel `RECORD` file is regenerated by pip with hashes of all
# OTHER files, so its byte-equality follows transitively from the
# rest — but it pins a relative path that pip canonicalises, so
# for the native-artefact diff we exclude RECORD explicitly and
# rely on the .py check above to catch any drift in inputs.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
runs-on: ubuntu-latest
container:
# PINNED manylinux_2_28 image (audit Issue 10 close-out). This
# is the toolchain anchor that makes native-artefact byte-equality
# achievable on a public CI runner. The tag is the date-stamped
# form `YYYY.MM.DD-N` (NOT `:latest`, NOT the floating
# `:manylinux_2_28` rolling tag) so this gate stays stable across
# the manylinux project's rolling updates. When a tag bump is
# needed (e.g., the pinned tag is yanked from the registry), do
# it as its own commit so the reproducible-build delta is
# auditable in isolation. Promoting to a `@sha256:` digest pin
# is a follow-up — INVARIANT-8 addendum.
#
# Tag verified against
# https://quay.io/api/v1/repository/pypa/manylinux_2_28_x86_64/tag/
# at PR #323 ready-for-review time (2026-05-20). The previous
# fabricated tag `:2026-04-21-f5ec593` did not exist and the
# docker pull failed with "manifest not found" — Copilot review
# #323 follow-up (CI failure on first run).
image: quay.io/pypa/manylinux_2_28_x86_64:2026.05.17-1
timeout-minutes: 30
# AR_FLAGS=Drcs was set here in the audit Issue 10 close-out
# commit but turned out to be a no-op: CMake's archive creation
# invokes `<CMAKE_AR> qc <TARGET> ...` directly and does NOT
# honour the GNU-make `AR_FLAGS` (or `ARFLAGS`) env var
# (Copilot review #323 round 2). Modern binutils' `ar` defaults
# to deterministic mode anyway (the `--enable-deterministic-archives`
# configure-time default since binutils 2.27, March 2016), so
# archive metadata determinism is implicit on every reasonable
# toolchain. If a future regression brings back a non-deterministic
# `ar`, the strict native-artefact diff below will catch it and
# the fix is to use `CMAKE_C_ARCHIVE_CREATE` overrides, not an
# env var the build doesn't read.
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install build prerequisites (manylinux_2_28 / AlmaLinux 8)
run: |
# The manylinux_2_28 image carries gcc-toolset, cmake, and
# CPython 3.9–3.13 under /opt/python/. Pre-install every
# build dep into the container's CPython 3.11 — `build` /
# `setuptools` / `wheel` / `cmake` / `Cython` / `numpy` —
# so the wheel build can run with `--no-isolation` below.
#
# WHY `--no-isolation`: PEP 517 build isolation creates a
# fresh `/tmp/build-env-<random8>/` directory per invocation
# and stages build deps inside it. When Cython compiles
# `.pyx -> .c -> .so`, the NumPy include path (and other
# header paths) inside that random directory get expanded
# via `__FILE__` macros into rodata strings inside the
# resulting native artefact. Two builds produce two
# different random suffixes → two different rodata strings
# → byte-different `.so` (and a downstream
# `--build-id=sha1` mismatch because the build-ID is a
# hash of section content). `--no-isolation` skips the
# random isolation dir entirely; build deps come from the
# container's pre-installed CPython site-packages, which
# is the same on every run. This is what the manylinux
# image is designed for.
/opt/python/cp311-cp311/bin/python -m pip install --upgrade pip
/opt/python/cp311-cp311/bin/pip install \
'build>=1.0' \
'setuptools>=78.1.1' 'wheel>=0.47.0' \
'cmake>=4.3.2' 'Cython>=3.2.4' 'numpy>=1.24.0'
# Front-load the manylinux Python on PATH so subsequent
# steps see `python` / `pip` as the pinned 3.11 from the
# base image (and not a stray host Python that GitHub's
# ubuntu host might have leaked through into the container).
echo "/opt/python/cp311-cp311/bin" >> "$GITHUB_PATH"
- name: Build wheel — pass 1
env:
AMA_BUILD_PIPELINE: "1"
SOURCE_DATE_EPOCH: "1700000000" # 2023-11-14 — stable reference epoch
PYTHONHASHSEED: "0"
PYTHONDONTWRITEBYTECODE: "1"
# CFLAGS — three overlapping prefix-map flags so every
# variant of host-path leakage gets neutralised inside
# the workspace tree:
# -fdebug-prefix-map : strips host paths from DWARF
# debug-info.
# -ffile-prefix-map : superset that ALSO covers __FILE__
# macro expansion (some C code
# embeds __FILE__ for logging).
# -fmacro-prefix-map : the macro half of file-prefix-map,
# explicit for older GCC that split
# the two flags.
# The PEP 517 isolation dir is handled separately via the
# `--no-isolation` flag on `python -m build` below — see
# the install step's WHY paragraph for the full rationale.
# LDFLAGS — `--build-id=sha1` makes the build-id a content
# hash instead of a fresh random value per link. Combined
# with deterministic section content, this produces an
# identical build-id across two passes.
# `MAKEFLAGS=-j1` and `CMAKE_BUILD_PARALLEL_LEVEL=1` force
# sequential compilation so parallel-build write-order
# variation cannot leak into the .so.
CFLAGS: "-fdebug-prefix-map=${GITHUB_WORKSPACE}=. -ffile-prefix-map=${GITHUB_WORKSPACE}=. -fmacro-prefix-map=${GITHUB_WORKSPACE}=."
LDFLAGS: "-Wl,--build-id=sha1"
MAKEFLAGS: "-j1"
CMAKE_BUILD_PARALLEL_LEVEL: "1"
run: |
rm -rf build dist
# `--no-isolation`: see Install step's WHY paragraph. The
# build deps are pre-installed into the container Python in
# the previous step, so the wheel build can run against
# them directly without staging into /tmp/build-env-<random>.
python -m build --wheel --no-isolation --outdir dist1/
ls -lh dist1/
- name: Build wheel — pass 2 (independent rebuild)
env:
AMA_BUILD_PIPELINE: "1"
SOURCE_DATE_EPOCH: "1700000000"
PYTHONHASHSEED: "0"
PYTHONDONTWRITEBYTECODE: "1"
CFLAGS: "-fdebug-prefix-map=${GITHUB_WORKSPACE}=. -ffile-prefix-map=${GITHUB_WORKSPACE}=. -fmacro-prefix-map=${GITHUB_WORKSPACE}=."
LDFLAGS: "-Wl,--build-id=sha1"
MAKEFLAGS: "-j1"
CMAKE_BUILD_PARALLEL_LEVEL: "1"
run: |
rm -rf build
python -m build --wheel --no-isolation --outdir dist2/
ls -lh dist2/
- name: Compare integrity digest + .py content across builds
run: |
# Extract bytewise contents from each wheel and assert
# equality on the load-bearing contract surface.
#
# IMPORTANT subtlety (INVARIANT-17 / Copilot review #322
# follow-up): `ama_cryptography/_integrity_signature.py` is
# NOT byte-stable across builds, and that is deliberate:
# `_build_sign.py` generates an *ephemeral* Ed25519 keypair
# per build, signs the SHA3-256 digest of the OTHER .py
# files with it, and writes the (public key, signature) pair
# into `_integrity_signature.py`. The private key is
# discarded immediately. So two builds against the same
# source tree legitimately produce two different
# _integrity_signature.py files — same INTEGRITY_DIGEST_HEX
# (computed by _compute_module_digest over the OTHER .py
# files, which IS what audit Issue 10 / INVARIANT-8 require
# be reproducible), but different (pubkey, signature).
#
# The check below therefore:
# 1. STRICT — INTEGRITY_DIGEST_HEX must match across both
# builds (the artefact whose stability the import-time
# gate depends on).
# 2. STRICT — every .py file EXCEPT _integrity_signature.py
# must be byte-identical across both builds (these are
# the files the digest is computed over; if any of
# them drifts, the digest above would also drift).
# 3. EXPECTED-TO-DIFFER — _integrity_signature.py itself.
# Reported as advisory in the log; not a fail trigger.
python - <<'PY'
import hashlib
import re
import sys
import zipfile
from pathlib import Path
INTEGRITY_SIG_BASENAME = "_integrity_signature.py"
def load_wheel(name: str):
wheels = sorted(Path(name).glob("*.whl"))
if not wheels:
sys.exit(f"FAIL: no wheel in {name}/")
return wheels[0]
def extract_py_and_digest(wheel: Path):
py_contents = {}
digest_hex = None
with zipfile.ZipFile(wheel) as zf:
for info in zf.infolist():
if not info.filename.endswith(".py"):
continue
data = zf.read(info.filename)
py_contents[info.filename] = data
if info.filename.endswith(INTEGRITY_SIG_BASENAME):
m = re.search(
r'INTEGRITY_DIGEST_HEX\s*=\s*"([0-9a-fA-F]+)"',
data.decode("utf-8"),
)
if m:
digest_hex = m.group(1)
if digest_hex is None:
sys.exit(f"FAIL: no INTEGRITY_DIGEST_HEX in {wheel}")
return py_contents, digest_hex
py1, d1 = extract_py_and_digest(load_wheel("dist1"))
py2, d2 = extract_py_and_digest(load_wheel("dist2"))
# (1) Strict — the digest itself must match. This is what
# audit Issue 10 / INVARIANT-8 ultimately gate on.
print(f"Pass 1 INTEGRITY_DIGEST_HEX: {d1}")
print(f"Pass 2 INTEGRITY_DIGEST_HEX: {d2}")
if d1 != d2:
sys.exit(
"FAIL (INVARIANT-8): INTEGRITY_DIGEST_HEX differs between "
"two builds with identical inputs. The wheel-integrity "
"gate at import time becomes a coin flip across "
"redeploys. Investigate the .py file set, environment, "
"and timestamps."
)
# (2) Strict — the file list must agree on every .py file
# EXCEPT _integrity_signature.py. The signature file is
# generated by both builds (no asymmetric presence), but
# we tolerate "present in both" / "missing in both" only
# after stripping it from the diff.
def strip_sig(names):
return {n for n in names if not n.endswith(INTEGRITY_SIG_BASENAME)}
set1 = strip_sig(py1)
set2 = strip_sig(py2)
if set1 != set2:
only_1 = sorted(set1 - set2)
only_2 = sorted(set2 - set1)
sys.exit(
f"FAIL: wheel .py file lists differ (ignoring signature).\n"
f" Only in pass 1: {only_1}\n"
f" Only in pass 2: {only_2}"
)
# (3) Strict — every .py file other than the signature must
# match byte-for-byte. The signature is in the same
# wheel but is EXPECTED to differ; we report its
# divergence at the bottom as advisory only.
mismatches = []
for name in sorted(set1):
if py1[name] != py2[name]:
h1 = hashlib.sha256(py1[name]).hexdigest()[:16]
h2 = hashlib.sha256(py2[name]).hexdigest()[:16]
mismatches.append(f" {name}: pass1={h1}... pass2={h2}...")
if mismatches:
sys.exit(
"FAIL: per-file .py content differs between builds "
"(excluding the ephemeral signature artefact):\n"
+ "\n".join(mismatches)
)
# (4) Advisory — surface the (expected) signature-file divergence
# so a reader scanning the log can confirm both wheels did
# ship a freshly signed integrity artefact (INVARIANT-17
# ephemeral per-build keypair behaviour).
sig1_name = next(
(n for n in py1 if n.endswith(INTEGRITY_SIG_BASENAME)), None
)
sig2_name = next(
(n for n in py2 if n.endswith(INTEGRITY_SIG_BASENAME)), None
)
if sig1_name and sig2_name:
same = py1[sig1_name] == py2[sig2_name]
print(
f"INFO: {INTEGRITY_SIG_BASENAME} byte-equal across builds: "
f"{same} (False is expected — see INVARIANT-17)."
)
print(f"OK: INTEGRITY_DIGEST_HEX {d1} stable across both builds.")
print(
f"OK: all {len(set1)} non-signature .py files inside the "
"wheel are byte-identical."
)
PY
- name: Diff native artefacts (STRICT)
# Audit Issue 10 close-out: promoted from ADVISORY to STRICT.
# `.so` / `.pyd` / Cython-built native artefacts must now be
# byte-identical across both passes. This is the affirmative
# side of INVARIANT-8 (deterministic reproducible builds).
#
# Exclusions:
# *.py — covered by the digest + .py byte-equality
# check above, including the legitimate
# INVARIANT-17 exemption for the per-build
# ephemeral `_integrity_signature.py`. Re-diffing
# them here would tautologically fail on that one
# file.
# RECORD — pip regenerates the wheel `RECORD` manifest
# per build; the manifest's hashes already cover
# every other artefact, so its byte-equality
# follows transitively from the rest.
#
# No `continue-on-error`, no `|| true`: a divergence here is a
# hard fail. When a previously-non-deterministic artefact
# becomes deterministic, tighten the exclude list — do NOT
# add a new per-path exemption to absorb a regression.
#
# On failure, emits per-file diagnostic info (size delta,
# first-divergence offset via `cmp -l`, and `objdump -h`
# section headers when the differing file is an ELF object)
# so a maintainer reading the CI log can identify the
# category of non-determinism (build-id, DWARF, sections,
# .note metadata, etc.) without having to download the wheel
# artefact and diff locally.
run: |
set -e
mkdir -p /tmp/w1 /tmp/w2
(cd /tmp/w1 && unzip -q "$GITHUB_WORKSPACE"/dist1/*.whl)
(cd /tmp/w2 && unzip -q "$GITHUB_WORKSPACE"/dist2/*.whl)
echo "=== Native artefact diff (strict) ==="
# Capture --brief output without exiting; the diagnostic
# block below depends on us reaching it.
diff_rc=0
differ=$(diff --brief --recursive --exclude='*.py' --exclude=RECORD \
/tmp/w1 /tmp/w2 || true)
if [ -z "$differ" ]; then
echo "OK: wheel native artefacts byte-equal across both builds."
exit 0
fi
echo "$differ"
echo
echo "=== Per-file divergence diagnostic ==="
# Parse `diff --brief`'s "Files X and Y differ" lines and
# emit size delta + first-divergence offset + (for ELF)
# section-header diff for each. Helps identify whether the
# delta is in .note.gnu.build-id, .debug_info, .text, .data,
# or somewhere else.
echo "$differ" | while IFS= read -r line; do
f1=$(echo "$line" | awk '{print $2}')
f2=$(echo "$line" | awk '{print $4}')
[ -f "$f1" ] && [ -f "$f2" ] || continue
sz1=$(stat -c '%s' "$f1")
sz2=$(stat -c '%s' "$f2")
echo "---"
echo "File: $(basename "$f1")"
echo " size pass1=$sz1 pass2=$sz2 delta=$((sz2 - sz1))"
# First-divergence offset via cmp.
off=$(cmp "$f1" "$f2" 2>&1 | head -1 || true)
echo " $off"
# If the file is an ELF object, dump and diff section
# header sizes so the maintainer can see which section
# carries the delta.
if file "$f1" 2>/dev/null | grep -q ELF; then
echo " ELF section-header diff (size column):"
diff <(objdump -h "$f1" | awk '/^ *[0-9]+ /{print $2,$3}') \
<(objdump -h "$f2" | awk '/^ *[0-9]+ /{print $2,$3}') | head -20 || true
fi
done
echo
echo "::error::Reproducible-build native-artefact diff failed — see per-file diagnostic above."
exit 1
- name: Upload both wheels for offline diff
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reproducible-build-wheels
path: |
dist1/*.whl
dist2/*.whl
retention-days: 14