deps(deps): Bump ruff from 0.15.15 to 0.15.16 #1749
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |