Skip to content

NIST P-256/384/521, ML-KEM/ML-DSA parameter sets, and key interoperability formats #2484

NIST P-256/384/521, ML-KEM/ML-DSA parameter sets, and key interoperability formats

NIST P-256/384/521, ML-KEM/ML-DSA parameter sets, and key interoperability formats #2484

Workflow file for this run

# Copyright (C) 2025-2026 Steel Security Advisors LLC
# SPDX-License-Identifier: Apache-2.0
name: CI - Testing and Code Quality
on:
push:
branches: [ main, develop, 'feature/**', 'fix/**' ]
pull_request:
branches: [ main, develop ]
workflow_dispatch:
permissions:
contents: read
# Collapse overlapping runs on the same ref to avoid runner-queue
# saturation when rapid pushes fire both push- and pull_request-triggered
# workflows in parallel. Cancelling in-flight runs is safe for feature
# branches and PR heads; `main` and scheduled runs are preserved by
# excluding them from ``cancel-in-progress``.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'schedule' }}
jobs:
test:
name: Test ${{ matrix.os }} / Python ${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
include:
# ARM64 (AArch64) runners — GitHub-hosted ubuntu arm64
- os: ubuntu-24.04-arm
python-version: "3.11"
- os: ubuntu-24.04-arm
python-version: "3.13"
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python ${{ matrix.python-version }}
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
continue-on-error: true
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
# First-party retry path: actions/setup-python intermittently fails on
# GitHub-hosted Windows runners during the Python 3.13 install
# (toolcache / cache-restore flake; see PR #306 review). The retry
# is non-fatal on the first miss and only re-runs when needed, so the
# happy path remains a single setup-python call.
- name: Set up Python ${{ matrix.python-version }} (retry without cache)
if: steps.setup-python.outcome == 'failure'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
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 build-essential cmake
- name: Install system dependencies (Windows)
if: runner.os == 'Windows'
shell: pwsh
# CMake is pre-installed on GitHub-hosted Windows runners
# (see https://github.com/actions/runner-images/ image manifests),
# so the common case is a no-op. Only install via Chocolatey —
# with retries — if ``cmake`` is genuinely missing, so a transient
# Chocolatey CDN outage cannot break the build. Matches the
# pattern already used in ``ci-build-test.yml``; PR #258 review
# fix for the flaky ``Install system dependencies (Windows)``
# step that was single-attempt ``choco install cmake``.
run: |
if (Get-Command cmake -ErrorAction SilentlyContinue) {
Write-Host "CMake already available: $(cmake --version | Select-Object -First 1)"
} else {
Write-Host "CMake not found; installing via Chocolatey with retries..."
$maxAttempts = 5
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Attempt ${attempt}/${maxAttempts}: choco install cmake..."
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System' -y 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Successfully installed cmake"
break
}
if ($attempt -lt $maxAttempts) {
$delay = $attempt * 15
Write-Host "Install failed, waiting ${delay}s before retry..."
Start-Sleep -Seconds $delay
}
}
if ($LASTEXITCODE -ne 0) {
Write-Error "All ${maxAttempts} attempts to install cmake via Chocolatey failed"
exit 1
}
Import-Module "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1"
refreshenv
}
- name: Build native C library (Linux)
if: runner.os == 'Linux'
run: |
cmake -B build -DAMA_USE_NATIVE_PQC=ON -DCMAKE_BUILD_TYPE=Release -DAMA_BUILD_TESTS=OFF -DAMA_BUILD_EXAMPLES=OFF
cmake --build build -j$(nproc)
- name: Build native C library (Windows)
if: runner.os == 'Windows'
run: |
cmake -B build -DAMA_USE_NATIVE_PQC=ON -DCMAKE_BUILD_TYPE=Release -DAMA_BUILD_TESTS=OFF -DAMA_BUILD_EXAMPLES=OFF
cmake --build build --config Release --parallel
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install "Cython>=3.0.0" "numpy>=1.24.0,<3.0.0"
# [legacy] + [benchmark] supply PyCA cryptography / PyNaCl, and
# pycryptodome backs the AES-GCM differential harness. Without them the
# cross-implementation validation tests (tests/test_differential.py,
# the PyCA interop cases in test_ed25519_native.py / test_aes_gcm_native.py
# / test_hkdf_sha3_256.py) silently SKIP, so a divergence between AMA's
# native C primitives and a reference implementation would go unnoticed.
# Installing them converts those skips into real assertions.
pip install -e ".[dev,legacy,benchmark]" pycryptodome
- name: Verify module integrity digest is current
shell: bash
run: |
python -c "
from ama_cryptography._self_test import verify_module_integrity
ok, detail = verify_module_integrity()
if not ok:
raise SystemExit(
f'FATAL: Module integrity digest is stale or tampered: {detail}\n'
'Run: python -m ama_cryptography.integrity --update'
)
print(f'Module integrity: OK ({detail})')
"
- name: Verify crypto backends are available
shell: bash
run: |
python -c "
from ama_cryptography.pqc_backends import (
DILITHIUM_AVAILABLE, KYBER_AVAILABLE, SPHINCS_AVAILABLE,
_ED25519_NATIVE_AVAILABLE, _AES_GCM_NATIVE_AVAILABLE,
)
backends = {
'Dilithium': DILITHIUM_AVAILABLE,
'Kyber': KYBER_AVAILABLE,
'SPHINCS+': SPHINCS_AVAILABLE,
'AES-GCM native': _AES_GCM_NATIVE_AVAILABLE,
'Ed25519 native': _ED25519_NATIVE_AVAILABLE,
}
missing = [name for name, avail in backends.items() if not avail]
for name, avail in backends.items():
status = 'OK' if avail else 'MISSING'
print(f' {name}: {status}')
if missing:
raise SystemExit(
f'FATAL: Crypto backends not available in CI: {missing}. '
'The C library must be built before running tests.'
)
print('All crypto backends verified.')
"
- name: Verify cross-implementation reference libraries are importable
shell: bash
# The differential / interop suites validate AMA's native C primitives
# against independent reference implementations. They are written to SKIP
# when a reference library is absent, which means a missing install would
# quietly delete that coverage instead of failing. Assert the imports here
# so the skip path can never silently return in CI.
run: |
python -c "
import importlib
refs = {
'cryptography': 'PyCA interop (Ed25519 / AES-GCM / HKDF cross-checks)',
'nacl': 'PyNaCl differential validation',
'Crypto': 'pycryptodome AES-GCM differential validation',
}
missing = []
for mod, why in refs.items():
try:
importlib.import_module(mod)
print(f' {mod}: OK ({why})')
except ImportError:
missing.append(f'{mod} ({why})')
print(f' {mod}: MISSING ({why})')
if missing:
raise SystemExit(
'FATAL: cross-implementation reference libraries missing in CI: '
f'{missing}. Interop tests would SKIP and silently drop coverage.'
)
print('All cross-implementation reference libraries verified.')
"
- name: Run pytest test suite
env:
AMA_CI_REQUIRE_BACKENDS: "1"
run: |
python -m pytest tests/ -v --tb=short --no-cov
- name: "Wycheproof corpus (vendored, offline, fail-closed)"
# Google's Project Wycheproof is a corpus of the cases that *break*
# implementations. Running it against this library for the first time
# found a real Ed25519 malleability defect (RFC 8032 §5.1.7) in under a
# minute — and then a second divergence in X25519 non-canonical
# u-coordinate handling. Neither had any standing gate; this is it.
#
# The corpus is vendored under wycheproof_vectors/ and pinned by
# manifest.json (upstream commit, per-file SHA-256, per-file vector
# count), so nothing is fetched at test time and a swapped vector file
# fails before a single vector runs. Every one of the 2,733 vectors
# lands in a named bucket with an asserted count — there is no silent
# skip, and an unrunnable vector is a red build with a reason.
run: |
python wycheproof_vectors/run_wycheproof.py
- name: Run demonstration
env:
PYTHONUTF8: "1"
run: |
python -m ama_cryptography
- name: Verify demonstration output
shell: bash
run: |
python -c "
import os
import subprocess
import sys
env = os.environ.copy()
env['PYTHONUTF8'] = '1'
result = subprocess.run([sys.executable, '-m', 'ama_cryptography'],
capture_output=True, text=True, timeout=60,
encoding='utf-8', errors='replace', env=env)
if result.returncode != 0:
print('ERROR: Demonstration failed')
print(result.stderr)
sys.exit(1)
output = result.stdout
# Verify critical outputs
checks = [
('AMA Cryptography', 'Title present'),
('Generating key management system', 'Key generation'),
('Creating Omni-Code cryptographic package', 'Package creation'),
('Signing package', 'Signing process'),
('Verifying cryptographic package', 'Verification'),
('ALL VERIFICATIONS PASSED', 'Success confirmation')
]
failed = []
for check, desc in checks:
if check not in output:
failed.append(f'{desc}: \"{check}\" not found')
if failed:
print('ERROR: Output validation failed:')
for fail in failed:
print(f' - {fail}')
sys.exit(1)
print('All verification checks passed')
"
code-quality:
name: Code Quality Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Pin the exact linter versions from requirements-lock.txt so the gate
# matches the pinned dev toolchain and pre-commit. An unpinned ruff
# (previously ``ruff>=0.4``) lets a routine ruff release start failing
# an unrelated PR; mypy floated on 1.x while the [dev] extra and
# [tool.mypy] config target 2.x, so local and CI could disagree.
# types-PyYAML keeps tools/check_workflow_commands.py and its tests
# inside `mypy --strict` rather than behind an ignore_missing_imports
# override; tests/ imports the tool, so mypy follows into it.
pip install "black==26.5.1" "ruff==0.15.20" "mypy==2.1.0" \
"PyYAML==6.0.3" "types-PyYAML==6.0.12.20260724"
- name: Lint with ruff
run: |
ruff check .
- name: Check code formatting with Black
run: |
black --check --diff .
- name: Type checking with MyPy (--strict)
continue-on-error: false
run: |
mypy --strict ama_cryptography/ tests/
- name: "INVARIANT-13: Suppression hygiene check"
run: |
python tools/check_suppression_hygiene.py
- name: "Uniform license headers (SPDX)"
# The tree carried five different header shapes at once — a two-line
# Apache note, the full thirteen-line boilerplate block, a bare SPDX
# tag on four files, a one-off "Apache License 2.0" spelling, and the
# C block-comment forms of each. No machine license scanner could
# read that reliably and a new file could pick any shape. This gate
# pins the canonical two-line header with the *registered* SPDX
# identifier (`Apache-2.0`; `Apache 2.0` is not a valid identifier).
# Detection and normalization are both pinned by tests/test_headers.py,
# so the gate cannot silently degrade into a no-op.
run: |
python tools/check_headers.py --check
docs-build:
# Run sphinx-build with -W on every PR / push so any docstring-format
# regression (e.g. malformed Raises: blocks, dangling indent) is caught
# at PR time, not on the post-merge auto-docs job whose failure does
# not block the merge that introduced it.
name: Sphinx Docs Build (Python API)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
cache: 'pip'
- name: Install documentation dependencies
run: |
python -m pip install --upgrade pip
python -m pip install sphinx sphinx_rtd_theme sphinx-autodoc-typehints
- name: Build Sphinx HTML — warnings are errors
env:
# INVARIANT-7 docs-build gate. Lift the native-backend import
# guards for autodoc only; ``--keep-going`` reports every warning
# in the run while ``-W`` makes the job fail on any of them.
AMA_SPHINX_BUILD: '1'
run: sphinx-build -W --keep-going -b html docs docs/_build/html
security-checks:
name: Security Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# PyYAML is pinned to the requirements-lock.txt version because
# tools/check_workflow_commands.py (INVARIANT-25) runs in this job and
# parses the workflow files structurally.
pip install pip-audit bandit "PyYAML==6.0.3"
- name: Audit dependencies (strict, lock-file scoped)
# Scope the audit to requirements-lock.txt — this project's pinned
# dependency set — rather than auditing the whole runner environment.
# A bare `pip-audit` reports CVEs in packages AMA does not ship (pip,
# pyjwt, urllib3 and whatever else the GitHub runner image preinstalls),
# so the gate turned red for reasons unrelated to this repository and
# unfixable from it. Lock-file scoping makes the result reproducible
# across reruns and runner-image rolls, and matches the contract already
# used by security.yml. `--strict` still fails the job on any
# vulnerability found in our own pinned set.
run: pip-audit --strict --desc --requirement requirements-lock.txt
- name: Security linting with Bandit
run: |
bandit -r ama_cryptography/ -l -f json -o bandit-report.json
bandit -r ama_cryptography/ -l
- name: Upload Bandit report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success()
with:
name: bandit-security-report
path: bandit-report.json
- name: Semgrep security scan
continue-on-error: false
run: |
python -m pip install semgrep==1.74.0
semgrep --config .semgrep.yml ama_cryptography/ --json -o semgrep-report.json
- name: Upload Semgrep report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success()
with:
name: semgrep-security-report
path: semgrep-report.json
- name: "INVARIANT-24: Verify pinned action SHAs resolve upstream"
# A SHA-pinned action is only a supply-chain control if the SHA is real.
# release.yml carried a pypa/cibuildwheel pin whose commit existed
# nowhere upstream, so EVERY wheel job aborted with "unable to find
# version" — which is why the v3.2.0 and v3.3.0 releases shipped with
# zero binary artefacts. Nothing caught it because release.yml only
# runs on a tag push. Checking here means a bad pin fails on the PR
# that introduces it, not on release day.
run: |
python tools/check_action_pins.py --strict
- name: "INVARIANT-25: Verify workflow runner labels and command strings"
# release.yml runs only on a tag push, so anything wrong inside it stays
# invisible until release day. Three defects shipped that way, each on
# its own enough to produce a release with zero binary artefacts: a
# retired `macos-13` runner label (the job queued until timeout), a
# `python -c` payload broken by YAML folding (every wheel built, then
# failed with IndentationError), and POSIX single-quoting handed to
# cmd.exe (every Windows wheel died on `Invalid requirement: "'cmake"`).
# All three are decidable without running anything, so they are decided
# here — on the pull request that introduces them. Both directions are
# pinned by tests/test_workflow_command_checks.py.
run: |
python tools/check_workflow_commands.py
- name: "INVARIANT-31: Verify every pull-request job is reachable from its gate"
# Branch protection requires the aggregating gate context of each
# workflow, not the individual job names. A job missing from its gate's
# `needs:` therefore still runs, still shows its own red X on the pull
# request, and still cannot block the merge — "all required checks
# passed" stays true next to a visibly failing job.
#
# `c-library-no-native-pqc` was exactly that: the guard for the
# AMA_USE_NATIVE_PQC=OFF build, absent from ci-build-test.yml's gate
# while commit f3dd0c2 had to repair that very configuration after it
# broke undetected. Every gate comment in this repository asserts that
# each job in the workflow must be green; this check is what makes that
# true. Both directions are pinned by tests/test_gate_coverage.py.
run: |
python tools/check_gate_coverage.py
- name: "INVARIANT-32: Verify documented install extras are declared"
# pip does not fail on an extra a distribution does not provide — it
# warns, installs WITHOUT it, and exits 0. A stale name in an install
# instruction therefore yields an incomplete install and a success
# message, surfacing much later as an ImportError from a subsystem the
# reader believes they enabled.
#
# wiki/Installation.md — published to the public wiki by wiki-sync.yml —
# shipped `pip install -e ".[secure-memory]"`, described as "libsodium
# secure memory bindings", in its own "Everything at once" line. No such
# extra ever existed, secure_memory is stdlib-only, and INVARIANT-1
# forbids libsodium outright. Both directions are pinned by
# tests/test_documented_extras.py.
run: |
python tools/check_documented_extras.py
- name: "INVARIANT-33: Verify every fuzz harness is registered everywhere"
# A fuzz harness is registered in three independent lists: the CMake
# target lists, the fuzzing.yml job matrix, and oss-fuzz/build.sh.
# Nothing tied them together, and they had drifted: fuzz_agent_binding
# was added to CMake and to the CI matrix when the agent-binding layer
# landed, and never to build.sh — so OSS-Fuzz never built it. build.sh
# skips a missing target with a warning and exits 0, which is why the
# omission stayed invisible.
#
# A harness nobody runs is indistinguishable from one that finds
# nothing. Both directions are pinned by
# tests/test_fuzz_target_registration.py.
run: |
python tools/check_fuzz_target_registration.py
- name: "INVARIANT-23: Secret scan (in-house)"
# tools/check_secrets.py is written in house rather than adopting a
# third-party scanner — see the module docstring for why a generic
# entropy scanner is the wrong tool for a repository whose tracked
# content is largely published high-entropy test vectors. Detection
# and non-detection are pinned by tests/test_secret_scanner.py, so this
# gate cannot silently degrade into a no-op.
run: |
python tools/check_secrets.py
- name: Enforce safe os.fdopen usage (fd-ownership, AST-verified)
# Replaces a grep-plus-filename-allowlist check. That construction could
# not tell a correctly guarded call from a leaking one — it only asked
# whether the file was on a list, so it was satisfied by editing the list
# — and it had already rotted: the allowlist named `key_storage.py`, a
# module that does not exist in this package, while omitting the module
# that actually performs the call. tools/check_fdopen_safety.py instead
# parses the AST and verifies the property that prevents the leak: every
# os.fdopen call sits in a try whose handlers (or finally) can close the
# raw descriptor when the hand-off fails. No allowlist to maintain, and
# it applies to every tracked module rather than three named ones.
# Both directions are pinned by tests/test_fdopen_safety.py.
run: |
python tools/check_fdopen_safety.py
benchmark-regression:
name: Benchmark Regression Detection (${{ matrix.runner_cpu_class }})
# x86_64 entry uses ubuntu-latest; arm64 entry uses ubuntu-24.04-arm
# (GitHub-hosted Cortex-A78 / Graviton-class AArch64 runner). The
# arm-baseline.json arm entry exercises the NEON paths wired by this
# PR (AES-GCM, ChaCha20, Argon2) end-to-end so any byte-level
# divergence between the NEON kernel and the scalar reference shows
# up as a regression-detector hit, not a silent passing build.
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
runner_cpu_class: x86_64
baseline_path: benchmarks/baseline.json
- os: ubuntu-24.04-arm
runner_cpu_class: aarch64
baseline_path: benchmarks/arm-baseline.json
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
cache: 'pip'
- name: Install system 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 build-essential cmake util-linux
- name: Capture runner CPU class
id: cpu_class
run: |
# lscpu reports the canonical "Architecture:" field which we
# treat as the source of truth. Pin the value into the runner
# matrix entry so a benchmark JSON produced by ubuntu-24.04-arm
# is unambiguously distinguishable from one produced by
# ubuntu-latest x86_64 (the regression detector keys off this).
ARCH=$(lscpu | awk -F: '/^Architecture:/ {gsub(/^ +/, "", $2); print $2}')
echo "arch=${ARCH}" >> "$GITHUB_OUTPUT"
echo "matrix_class=${{ matrix.runner_cpu_class }}" >> "$GITHUB_OUTPUT"
- name: Build native C library
run: |
# AMA_ENABLE_AVX512=ON enables the in-house AVX-512 4-way Keccak
# kernel on x86-64 hosts (gated on runtime CPUID at dispatch
# init, so a no-op on AArch64). AMA_ENABLE_NATIVE_ARCH=ON adds
# -march=native so the benchmark exercises the host's full
# microarchitecture (AES-NI, BMI2, AVX2/AVX-512, NEON Crypto
# Extensions as appropriate). Without these flags the benchmark
# numbers reflect the lowest-common-denominator generic C path,
# not what users actually run.
cmake -B build \
-DAMA_USE_NATIVE_PQC=ON \
-DCMAKE_BUILD_TYPE=Release \
-DAMA_BUILD_TESTS=OFF \
-DAMA_BUILD_EXAMPLES=OFF \
-DAMA_ENABLE_AVX512=ON \
-DAMA_ENABLE_NATIVE_ARCH=ON
cmake --build build -j$(nproc)
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install "Cython>=3.0.0" "numpy>=1.24.0,<3.0.0"
pip install -e ".[dev]"
- name: Run benchmark regression tests
env:
AMA_RUNNER_CPU_CLASS: ${{ steps.cpu_class.outputs.arch }}
AMA_MATRIX_BASELINE: ${{ matrix.baseline_path }}
run: |
# Pin to a single core (`taskset -c 0`) and raise scheduling
# priority (`nice -n -10`) so the benchmark numbers are not
# contaminated by other tasks on the shared GitHub-hosted
# runner. Mirrors the same harness dudect.yml uses for its
# constant-time measurements at the identical priority — the
# dudect t-statistic and the benchmark medians both depend on
# the same noise floor.
#
# `--baseline "$AMA_MATRIX_BASELINE"` is the per-matrix entry
# baseline path (Copilot review #3249188319): the x86 entry
# compares against benchmarks/baseline.json, the AArch64 entry
# against benchmarks/arm-baseline.json. Without this, both
# entries default to the x86 baseline and the AArch64 job
# reports spurious regressions on every PR.
taskset -c 0 nice -n -10 python benchmarks/benchmark_runner.py \
--verbose \
--baseline "$AMA_MATRIX_BASELINE" \
--require-runner-class "$AMA_RUNNER_CPU_CLASS" \
--require-populated-baseline \
--output benchmarks/benchmark-results.json \
--markdown benchmark-report.md
# Annotate the result JSON with the runner CPU class so the
# arm and x86 artefacts are unambiguously distinguishable.
# The downstream `check_baseline_justification.py` uses this
# tag to compare against the matching baseline.
python - <<'PY'
import json, os, pathlib
path = pathlib.Path("benchmarks/benchmark-results.json")
if path.exists():
data = json.loads(path.read_text())
data.setdefault("metadata", {})["runner_cpu_class"] = (
os.environ.get("AMA_RUNNER_CPU_CLASS", "unknown")
)
data["metadata"]["matrix_baseline_path"] = (
os.environ.get("AMA_MATRIX_BASELINE", "")
)
path.write_text(json.dumps(data, indent=2) + "\n")
PY
- name: Upload benchmark results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success()
with:
name: benchmark-results-${{ matrix.runner_cpu_class }}
path: |
benchmarks/benchmark-results.json
benchmark-report.md
constant-time-check:
name: Constant-Time Verification (Smoke Test)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install build 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 build-essential cmake
- name: Build C library
run: |
mkdir -p build
cd build
cmake ..
make
- name: Build and run dudect harnesses
run: |
cd tools/constant_time
make clean
make all
# Run smoke tests with 50K iterations (faster for CI).
#
# Pin to a single core (`taskset -c 0`) and best-effort
# elevate priority (`nice -n -10`) to suppress CPU-contention
# noise on shared GitHub-hosted runners. Without this, the
# dudect t-statistic at 50K iterations can drift past its
# leakage threshold from background noise alone.
#
# GHA hosted runners lack CAP_SYS_NICE so the negative-nice
# request prints "Permission denied" but still execs the
# binary; probe once up front and drop the prefix when the
# runner refuses (PR #326), so the log stays clean. The
# v3.2.0 dudect setup-symmetry harness fixes made the lanes
# noise-tolerant enough that taskset-only pinning is the
# load-bearing CI gate; the nice prefix is opportunistic on
# privileged self-hosted runners. The full dudect.yml gate
# (path-filtered to src/c/** changes) remains the
# authoritative constant-time check; this smoke test exists
# as a fast pre-gate for every PR regardless of path filter.
NICE_PREFIX=""
if nice -n -10 true 2>/dev/null; then NICE_PREFIX="nice -n -10"; fi
echo "=== Utility function timing analysis ==="
taskset -c 0 $NICE_PREFIX ./dudect_harness 50000
echo "=== Crypto primitive timing analysis ==="
taskset -c 0 $NICE_PREFIX ./dudect_crypto 50000
- name: Upload timing analysis results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success()
with:
name: constant-time-results
path: tools/constant_time/
# ============================================================================
# AVX-512 SHA3 4-way KAT (PR C — 2026-04)
#
# Best-effort gate: GitHub-hosted ubuntu-latest currently rotates among
# Cascade Lake / Ice Lake / Sapphire Rapids host CPUs, so AVX-512 hits
# only some fraction of runs. We probe /proc/cpuinfo for the avx512f
# feature flag and skip honestly when it's missing — the build/test
# body itself never uses continue-on-error (INVARIANT-2: fail-closed
# CI on the steps that actually run). Local validation ladder for
# pre-merge: SDE (`sde64 -spr -- ./test_sha3_avx512_kat`) plus a
# quarterly bare-metal bench post-merge.
# ============================================================================
test-avx512:
name: AVX-512 SHA3 4-way KAT
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Probe /proc/cpuinfo for AVX-512F + AVX-512VL
id: cpu
run: |
if grep -q '\bavx512f\b' /proc/cpuinfo && grep -q '\bavx512vl\b' /proc/cpuinfo; then
echo "have_avx512=1" >> "$GITHUB_OUTPUT"
echo "::notice::ubuntu-latest runner reports AVX-512F + AVX-512VL — running KAT"
else
echo "have_avx512=0" >> "$GITHUB_OUTPUT"
echo "::notice::ubuntu-latest runner lacks AVX-512F or AVX-512VL — skipping KAT job"
fi
- name: Install build dependencies
if: steps.cpu.outputs.have_avx512 == '1'
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 build-essential
- name: Configure with AVX-512 enabled
if: steps.cpu.outputs.have_avx512 == '1'
run: cmake -S . -B build -DAMA_ENABLE_AVX512=ON -DAMA_BUILD_TESTS=ON
- name: Build AVX-512 KAT test
if: steps.cpu.outputs.have_avx512 == '1'
run: cmake --build build --target test_sha3_avx512_kat --parallel
- name: Run AVX-512 KAT test (byte-identity vs scalar + AVX2)
if: steps.cpu.outputs.have_avx512 == '1'
run: ctest --test-dir build -R test_sha3_avx512_kat --output-on-failure
ed25519-backend-differential:
name: Ed25519 backend differential (donna vs fe51)
# WHY THIS JOB EXISTS
#
# This repository ships TWO Ed25519 implementations and picks between them
# at configure time (CMakeLists.txt removes src/c/ama_ed25519.c from the
# source list and substitutes src/c/ed25519_donna_shim.c whenever
# AMA_ED25519_ASSEMBLY is ON, which is the default on x86-64):
#
# * ed25519-donna — x86-64 assembly, what every x86-64 lane builds
# * fe51 — the portable path in ama_ed25519.c
#
# Until this job existed, no CI lane ever built fe51 on x86-64. It was
# exercised only incidentally on the ubuntu-24.04-arm runners, and nothing
# anywhere compared the two against each other.
#
# That is not a theoretical gap. INVARIANT-26 fixed an RFC 8032 §5.1.7
# canonical-S defect that was present in BOTH backends but for DIFFERENT
# reasons — donna's `RS[63] & 224` test was too weak, while fe51 had no
# range check at all — so a fix written against one could easily have left
# the other broken. Two implementations of one public API that nothing
# compares will drift; the only question is when it is noticed.
#
# This job builds both on the same runner and asserts they return the same
# verdict for every signature in a shared corpus. It is a differential
# test, not a KAT: it does not care which answer is right, only that the
# two backends cannot disagree without failing the build.
runs-on: ubuntu-latest
# Two full CMake builds of the native library plus a C test run. Measured
# at roughly 4 minutes locally; 30 leaves headroom for a cold runner
# without letting a genuinely hung build sit for an hour.
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install build toolchain
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake
- name: Build donna backend (x86-64 default)
run: |
cmake -S . -B build-donna \
-DAMA_ED25519_ASSEMBLY=ON \
-DAMA_USE_NATIVE_PQC=ON \
-DCMAKE_BUILD_TYPE=Release \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF
cmake --build build-donna -j"$(nproc)"
- name: Build fe51 backend (portable path, never built on x86-64 before)
run: |
cmake -S . -B build-fe51 \
-DAMA_ED25519_ASSEMBLY=OFF \
-DAMA_USE_NATIVE_PQC=ON \
-DCMAKE_BUILD_TYPE=Release \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=OFF
cmake --build build-fe51 -j"$(nproc)"
- name: Run the C test suite against the fe51 backend
# The donna backend's C tests already run in ci-build-test.yml on every
# x86-64 lane. This is the half that had no coverage.
working-directory: build-fe51
run: ctest --output-on-failure
- name: Differential — both backends must agree on every signature
run: |
python tools/check_ed25519_backend_parity.py \
--donna build-donna/lib/libama_cryptography.so \
--fe51 build-fe51/lib/libama_cryptography.so
ci-gate:
name: CI Gate
# Single aggregating status check for this workflow. Branch protection
# should require ONLY this context (one per primary CI workflow) instead of
# the individual job names: adding, renaming, or matrix-expanding a job then
# updates this needs: list under code review rather than drifting the
# branch-protection config out of sync (required-context drift). `needs:` is
# workflow-local, so each of ci.yml / ci-build-test.yml / static-analysis.yml
# carries its own gate.
#
# if: always() so the gate always resolves to a definitive red/green even
# when a dependency fails — otherwise it would be reported as `skipped` and
# leave the PR stuck "waiting for status".
if: always()
needs:
- test
- code-quality
- docs-build
- security-checks
- benchmark-regression
- constant-time-check
- test-avx512
- ed25519-backend-differential
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Fail unless every required job succeeded
# STRICT. Every job in this workflow runs unconditionally — none carries a
# job-level `if:` — so on the authoritative head-commit run each MUST be
# `success`. `skipped` now fails the gate too: on this workflow it can only
# mean a `needs:` dependency failed (already red) or a future job's `if:`
# drifted so it silently stopped running — exactly the gap a lenient gate
# hides. `cancelled` also fails, but only ever appears on a run superseded
# by a newer push or a manual abort, neither of which gates the latest head
# commit that branch protection evaluates. Matrix jobs are fail-fast:false
# and timeouts report as `failure`.
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled') }}
run: |
echo "::error::CI Gate failed — a required job did not succeed."
echo "Dependency results: ${{ join(needs.*.result, ', ') }}"
exit 1
- name: All required jobs succeeded
run: echo "CI Gate passed — all required jobs are green."