Skip to content

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

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 #2066

Workflow file for this run

# Copyright (C) 2025-2026 Steel Security Advisors LLC
# SPDX-License-Identifier: Apache-2.0
name: CI - Build and Test
on:
push:
branches: [ main, develop, 'feature/**', 'fix/**' ]
pull_request:
branches: [ main, develop ]
workflow_dispatch:
# Collapse overlapping runs on the same ref; feature branches and PR
# heads cancel in-flight runs, main is preserved.
concurrency:
group: ci-build-test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'schedule' }}
jobs:
# C Library Build and Test
c-library:
name: C Library (${{ matrix.os }}, ${{ matrix.compiler }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
compiler: [gcc, clang]
include:
- os: ubuntu-latest
compiler: gcc
cc: gcc-13
cxx: g++-13
- os: ubuntu-latest
compiler: clang
cc: clang-18
cxx: clang++-18
- os: macos-latest
compiler: gcc
cc: gcc-12
cxx: g++-12
- os: macos-latest
compiler: clang
cc: clang
cxx: clang++
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
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 libssl-dev ${{ matrix.cc }} ${{ matrix.cxx }}
- name: Install dependencies (macOS)
if: matrix.os == 'macos-latest'
run: |
brew install cmake gcc@12
- name: Configure CMake
run: |
mkdir -p build
cd build
# Set compiler path
if [ "$RUNNER_OS" = "macOS" ]; then
# For GCC on macOS, use full path
if [ "${{ matrix.compiler }}" = "gcc" ]; then
CC_PATH=$(brew --prefix gcc@12)/bin/gcc-12
CXX_PATH=$(brew --prefix gcc@12)/bin/g++-12
else
CC_PATH=${{ matrix.cc }}
CXX_PATH=${{ matrix.cxx }}
fi
else
CC_PATH=${{ matrix.cc }}
CXX_PATH=${{ matrix.cxx }}
fi
cmake .. \
-DCMAKE_C_COMPILER=${CC_PATH} \
-DCMAKE_CXX_COMPILER=${CXX_PATH} \
-DCMAKE_BUILD_TYPE=Release \
-DAMA_BUILD_SHARED=ON \
-DAMA_BUILD_STATIC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_ENABLE_AVX2=ON
- name: Build
# PR #326 follow-up: re-emit per-command verbose output on
# failure so the actual compiler diagnostic surfaces in the
# job log without needing local repro. The parallel-j4 form
# is the fast happy path; the serial-verbose form runs only
# if the fast path returned non-zero AND propagates that
# non-zero exit so the step still fails. Diagnoses the
# otherwise opaque "Process completed with exit code 2"
# surfaced on the macos-latest-clang lane that's been red
# since `58e7a2d` introduced the dispatch cache code.
shell: bash
run: |
set -o pipefail
if cmake --build build --config Release -j4; then
exit 0
fi
rc=$?
echo "::group::Re-running build with --verbose to surface the failing command"
# `--clean-first` discards the partial build state from the
# failed parallel pass so the verbose pass actually re-runs
# the failing compile (otherwise cmake/ninja/make caches the
# failure and just exits non-zero without re-emitting it).
cmake --build build --config Release --verbose --clean-first -j1 \
2>&1 | tee build-verbose.log || true
echo "::endgroup::"
# First few lines of the verbose log are the most useful —
# they show the failing TU and the diagnostic text immediately,
# without the reviewer having to scroll through every
# successful compile that ran before it.
if [ -s build-verbose.log ]; then
echo "::group::First failure block from verbose log"
grep -m1 -B0 -A40 'error:' build-verbose.log || tail -80 build-verbose.log
echo "::endgroup::"
fi
exit "$rc"
- name: Test
# `--output-on-failure` + `--verbose` together print the full
# stdout/stderr of every failing test directly into the CI log,
# plus a one-line per-passing-test trace. Without `--verbose`
# ctest swallows the diagnostic output of an exit-code-only
# failure, which is how the `C Library (*, clang)` lanes
# previously reported only "Process completed with exit code 8"
# without ever revealing which 8 tests failed. PR #326
# follow-up: every failing C test now self-diagnoses in the
# job log.
run: cd build && ctest --output-on-failure --verbose
# ---------------------------------------------------------------------
# AMA_USE_NATIVE_PQC=OFF configuration guard
# ---------------------------------------------------------------------
# ON is and remains the default (top-level CMakeLists.txt) and the only
# configuration setup.py will build — INVARIANT-7 forbids a cryptographic
# fallback, so the shipped wheel always carries the native PQC backends.
# OFF is nonetheless a supported CMake configuration for downstream
# packagers who take PQC from elsewhere, and it is exactly the kind of
# cell that silently rots when nothing builds it: before this job existed,
# `-DAMA_USE_NATIVE_PQC=OFF` left dispatch code referencing four Kyber/
# Dilithium NTT reference symbols defined only in the gated PQC source
# group.
#
# The observable failure is toolchain-dependent, and the guard catches it
# either way. On this Linux lane GNU ld does NOT set --no-undefined on the
# shared library, so libama_cryptography.so links with those four symbols
# left UNDEFINED; what actually fails is every EXECUTABLE that links it —
# the always-built test binaries and the non-PQC examples — with
# "undefined reference to ama_dilithium_ntt_generic_ref". On macOS/Windows
# the shared-library link itself would fail. The build step below fails on
# the first of these, and the ctest step then proves the tests that survive
# the gate (including test_agent_binding, which needs no PQC symbol) still
# pass rather than merely compile. Fail-closed: no continue-on-error.
c-library-no-native-pqc:
name: C Library (AMA_USE_NATIVE_PQC=OFF configuration guard)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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 gcc-13 g++-13
- name: Configure CMake (native PQC disabled)
run: |
cmake -B build-nopqc \
-DCMAKE_C_COMPILER=gcc-13 \
-DCMAKE_CXX_COMPILER=g++-13 \
-DCMAKE_BUILD_TYPE=Release \
-DAMA_USE_NATIVE_PQC=OFF \
-DAMA_BUILD_SHARED=ON \
-DAMA_BUILD_STATIC=ON \
-DAMA_BUILD_TESTS=ON \
-DAMA_BUILD_EXAMPLES=ON
- name: Build
run: cmake --build build-nopqc --config Release -j4
- name: Test
run: cd build-nopqc && ctest --output-on-failure
- name: Assert the default is still ON
# A regression that flipped the default would make the job above green
# for the wrong reason (it would test the default path twice). Assert
# CMake's RESOLVED value from a default configure (no -D override), not
# a regex over the source line: a source regex both false-passes on a
# trailing '... ON)' comment and false-fails on CMake-valid ON spellings
# (TRUE / 1 / lowercase on). This reads the value CMake actually
# computed, so it is immune to how the option line is written.
shell: bash
run: |
set -o pipefail
cmake -B build-default-probe \
-DCMAKE_C_COMPILER=gcc-13 \
-DCMAKE_CXX_COMPILER=g++-13 \
-DCMAKE_BUILD_TYPE=Release >/dev/null
resolved=$(cmake -LA -N build-default-probe | grep '^AMA_USE_NATIVE_PQC:' || true)
echo "resolved default: ${resolved:-<absent>}"
if [ "$resolved" != "AMA_USE_NATIVE_PQC:BOOL=ON" ]; then
echo "::error::AMA_USE_NATIVE_PQC no longer defaults to ON (resolved: ${resolved:-<absent>})"
exit 1
fi
echo "AMA_USE_NATIVE_PQC default is ON."
# Python Package Build and Test
python-package:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- 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 }}
# First-party retry path: actions/setup-python intermittently fails on
# GitHub-hosted Windows runners (toolcache / cache-restore flake; see
# PR #306). Only runs on the first miss, so happy path is unchanged.
- name: Set up Python ${{ matrix.python-version }} (retry)
if: steps.setup-python.outcome == 'failure'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
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 python3-dev cmake libssl-dev
- name: Install system dependencies (macOS)
if: matrix.os == 'macos-latest'
run: brew install cmake
- name: Install system dependencies (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
# CMake is pre-installed on GitHub-hosted Windows runners.
# Only install via Chocolatey (with retries) if it is missing,
# so a transient Chocolatey CDN outage cannot break the build.
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
}
# Refresh PATH so cmake is available to subsequent steps
Import-Module "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1"
refreshenv
}
- name: Install Python build dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install "Cython>=3.0.0" "numpy>=1.24.0,<3.0.0"
# Pre-build the native C library so the install step can rely on
# an already-populated ``build/`` directory. Mirrors the
# proven-working pattern from ``ci.yml::test`` and avoids the
# latent setup.py CMakeBuild fragility on Windows / macOS that
# surfaces as "Python X.Y on {os} -- exit 1/2" without the
# pre-build.
- name: Build native C library (Linux / macOS)
if: matrix.os != 'windows-latest'
run: |
cmake -B build -DAMA_USE_NATIVE_PQC=ON -DCMAKE_BUILD_TYPE=Release -DAMA_BUILD_TESTS=OFF -DAMA_BUILD_EXAMPLES=OFF
# Use Python's os.cpu_count() for portability — `nproc` is GNU
# coreutils and not guaranteed on macos-latest. Bare `-j` would
# unbound make's parallelism and OOM the runner.
cmake --build build -j"$(python -c 'import os; print(os.cpu_count() or 1)')"
- name: Build native C library (Windows)
if: matrix.os == 'windows-latest'
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 + package (with [dev] extras)
# Use the [dev] extras so hypothesis / pytest-cov / mypy / etc.
# come in via the canonical pyproject.toml declaration rather
# than the legacy requirements-dev.txt. Matches ci.yml::test.
run: pip install -e ".[dev]"
- name: Install differential/interop reference libraries (test-only)
# Independent third-party implementations that let the cross-library
# differential tests (tests/test_differential.py and the PyCA-interop
# tests) actually EXECUTE rather than skip — previously they were dead
# in every CI lane because these libs were never installed. They are
# test-only references, NOT a runtime dependency (INVARIANT-1 forbids
# product code importing them; the invariant lint enforces that only
# ama_cryptography/*.py stays clean). Linux-gated to avoid cross-OS
# wheel flakiness on the primary differential lane.
if: runner.os == 'Linux'
run: pip install cryptography pynacl pycryptodome
- name: Verify module integrity
run: python -m ama_cryptography.integrity --verify
- name: Run tests
env:
# Force backend availability check to fail loudly if the
# native C library didn't build / didn't load — otherwise
# tests silently skip and mask backend regressions.
AMA_CI_REQUIRE_BACKENDS: "1"
# UTF-8 encoding for Windows to support Unicode symbols in output
PYTHONIOENCODING: ${{ matrix.os == 'windows-latest' && 'utf-8' || '' }}
PYTHONUTF8: ${{ matrix.os == 'windows-latest' && '1' || '' }}
run: pytest tests/ -v --tb=short --cov=ama_cryptography --cov-report=xml
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: python
name: python-${{ matrix.python-version }}
# Linting and Code Quality
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- 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: |
# Pinned to requirements-lock.txt so this gate matches the dev
# toolchain and pre-commit (no unpinned ruff, no mypy 1.x/2.x split).
# types-PyYAML keeps tools/check_workflow_commands.py inside
# `mypy --strict`; tests/ imports it, 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 (ruff)
run: ruff check .
- name: Check formatting (black)
run: black --check --diff .
- name: Type check (mypy --strict)
run: mypy --strict ama_cryptography/ tests/
continue-on-error: false
# Security Audit
security:
name: Security Audit
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- 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
pip install pip-audit bandit
# Use the audit-time dependency closure so pip-audit's strict
# mode (below) is reproducible across runner image versions.
pip install -r requirements-lock.txt
# FAIL-CLOSED on the LOCK FILE (INVARIANT-2 / audit Issue 8 +
# Copilot review #322): --strict gates merge on any vulnerable
# package; `--requirement requirements-lock.txt` scopes the audit
# to the pinned dependency closure rather than whatever the
# `pip install pip-audit bandit` step transitively pulled in,
# so the gate is reproducible across reruns. CVE ignores must
# be declared in INVARIANT-14's table with the canonical
# comment, never silently here.
- name: Audit dependencies (strict, lock-file scoped)
run: pip-audit --strict --desc --requirement requirements-lock.txt
# FAIL-CLOSED on Bandit Medium+: any unjustified Medium/High
# finding fails the build. Justified suppressions must carry an
# inline `# nosec` with a tracking ID, validated by
# tools/check_suppression_hygiene.py per INVARIANT-13.
- name: Security scan (bandit, fail on Medium+)
run: |
bandit -r ama_cryptography/ \
--severity-level medium \
--confidence-level medium \
--exit-zero \
> bandit-medium-plus.txt
echo "=== Bandit Medium/High findings ==="
cat bandit-medium-plus.txt
if grep -E '^\s*(Medium|High):\s*[1-9]' bandit-medium-plus.txt; then
echo "::error::Bandit reports unjustified Medium/High findings — see report above."
exit 1
fi
# Benchmark Regression Detection (Wheel Install)
# Required job — fails the workflow on detected regression.
# Environment variance on shared CI runners (25-50%) means baselines
# must account for ctypes-fallback throughput, not Cython-optimized peaks.
#
# NAME DISAMBIGUATION: ci.yml::benchmark-regression also produces a
# "Benchmark Regression Detection" status check via an editable install
# (`pip install -e ".[dev]"`). This job is deliberately the wheel-install
# variant — it builds a real wheel via PEP 517 (`python -m build --wheel`),
# installs that wheel, and runs the benchmark harness from a directory
# outside the source tree so the install under test cannot be shadowed
# by `./ama_cryptography/`. Packaging-time regressions (missing
# MANIFEST.in entries, missing package_data, broken setup.py CMakeBuild
# paths, incomplete pyproject.toml [build-system].requires) only surface
# in this variant — they're invisible to an editable install.
# The two checks must remain independently required in branch rulesets.
benchmark-regression:
name: Benchmark Regression Detection (Wheel Install)
runs-on: ubuntu-latest
timeout-minutes: 25
needs: [c-library]
steps:
- 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 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 libssl-dev
- name: Build wheel via PEP 517 (isolated build env, signed integrity)
# `python -m build --wheel` runs the build in an isolated env that
# only sees pyproject.toml's [build-system].requires — so a missing
# build-time dep (e.g., Cython, cmake, numpy) fails closed here
# rather than silently falling through on a workstation that
# happened to have it. setup.py's CMakeBuild compiles the native
# C library inside the build, so the resulting wheel ships the
# compiled libama_cryptography artifact.
#
# AMA_BUILD_PIPELINE=1 enables setup.py's post-build integrity
# signer (CMakeBuild._run_integrity_signer) so the wheel ships an
# Ed25519-signed integrity artefact, not just the digest-only
# fallback. The signer uses the in-tree ama_ed25519 C kernel via
# ctypes (INVARIANT-1: no PyCA dependency). Tagged releases can
# additionally set AMA_INTEGRITY_TRUST_ANCHOR_PUBKEY_HEX +
# AMA_INTEGRITY_REQUIRE_TRUST_ANCHOR=1 in the release workflow to
# produce trust-anchored wheels — this PR-level job uses a
# per-build ephemeral pubkey (still verified at import time).
env:
AMA_BUILD_PIPELINE: "1"
run: |
python -m pip install --upgrade pip
pip install build
python -m build --wheel --outdir dist/
ls -lh dist/
- name: Install built wheel (no source-tree shadow)
# Plain `pip install dist/*.whl` — no `-e`, no fallback to source.
# numpy is only needed to import the optional math_engine extension
# transitively touched by some benchmarks; it's not declared in the
# base wheel's runtime deps because INVARIANT-1 keeps the runtime
# closure dependency-free.
run: |
pip install dist/*.whl
pip install "numpy>=1.24.0,<3.0.0"
- name: Run benchmark regression detection (from temp dir, wheel-only)
# cd OUT of $GITHUB_WORKSPACE so `import ama_cryptography` cannot
# resolve to ./ama_cryptography (the working-tree shadow that
# would silently turn this job into ci.yml's editable variant).
# The site-packages assertion makes the shadow regression noisy:
# if a future commit accidentally re-introduces the editable
# install, this assertion fails the job before benchmarks run.
env:
SRCDIR: ${{ github.workspace }}
run: |
mkdir -p "$RUNNER_TEMP/wheel-bench"
cd "$RUNNER_TEMP/wheel-bench"
python -c "
import ama_cryptography
loc = ama_cryptography.__file__
assert 'site-packages' in loc, (
f'wheel install not active — ama_cryptography loaded from {loc!r}; '
'editable / source-tree shadow regression. Re-check the install step.'
)
print(f'OK: ama_cryptography loaded from {loc}')
"
python "$SRCDIR/benchmarks/benchmark_runner.py" \
--verbose \
--baseline "$SRCDIR/benchmarks/baseline.json" \
--output "$SRCDIR/benchmarks/regression_results.json" \
--markdown "$SRCDIR/benchmarks/regression_report.md"
- name: Upload regression report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: benchmark-regression-report
path: |
benchmarks/regression_results.json
benchmarks/regression_report.md
dist/*.whl
# Docker Build — gating: the smoke tests at the bottom of this job
# exercise the exact path a downstream consumer hits on `docker pull`,
# so a build/runtime regression here is a customer-visible regression
# and must block merge. Transient Docker Hub auth/rate-limit failures
# are mitigated in-job via:
# * Pre-pulling the BuildKit image with 8 retries + capped exponential
# backoff (~340s of tolerance)
# * Optional Docker Hub login (gated on both DOCKERHUB_USERNAME and
# DOCKERHUB_TOKEN being set, so a half-configured fork degrades
# cleanly rather than failing noisily)
# If you find yourself wanting to re-add continue-on-error here,
# diagnose the underlying flake instead — silencing this gate masks
# exactly the class of regression it exists to catch.
docker:
name: Docker Build
runs-on: ubuntu-latest
timeout-minutes: 20
# Job-level env so the optional-login step can gate on env.* in `if:`.
# `secrets.*` is not allowed in step-level `if:` expressions, which
# otherwise causes a workflow-validation error before any job runs.
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Pre-pull BuildKit image (with retries)
run: |
# Pre-pull the BuildKit image used by setup-buildx-action to
# survive transient Docker Hub outages (504, network timeouts).
#
# Widened from 5 attempts / ~150s to 8 attempts / ~340s after
# Docker Hub timeouts blocked PR #370 three times in one day.
# The backoff is capped rather than doubling without bound: past
# ~90s per gap the extra wait buys little and the job's 20-minute
# timeout starts to bind.
#
# This is a retry, not a bypass. If the image genuinely cannot be
# fetched the job still proceeds to buildx setup and fails there
# on a real error. Do NOT add continue-on-error here (INVARIANT-2)
# — the smoke tests below exercise the exact path a downstream
# consumer hits on `docker pull`, so silencing this job hides a
# customer-visible regression.
IMAGE="moby/buildkit:buildx-stable-1"
# Try the mirror BEFORE Docker Hub on each attempt. mirror.gcr.io is
# a pull-through cache that serves library and official images
# without a token, so it is not subject to Docker Hub's
# unauthenticated rate limit — which is the actual failure mode this
# ladder exists for. Retagging makes the pulled image satisfy
# setup-buildx-action's reference either way.
MIRROR="mirror.gcr.io/moby/buildkit:buildx-stable-1"
BACKOFF="10 20 40 60 60 60 90"
attempt=1
for delay in ${BACKOFF} final; do
echo "Attempt ${attempt}/8: pulling ${IMAGE} via mirror..."
if docker pull "${MIRROR}"; then
docker tag "${MIRROR}" "${IMAGE}"
echo "Successfully pulled ${IMAGE} from the mirror on attempt ${attempt}"
exit 0
fi
echo "Mirror miss; falling back to Docker Hub for attempt ${attempt}..."
if docker pull "${IMAGE}"; then
echo "Successfully pulled ${IMAGE} from Docker Hub on attempt ${attempt}"
exit 0
fi
if [ "${delay}" = "final" ]; then
break
fi
echo "Pull failed, waiting ${delay}s before retry..."
sleep "${delay}"
attempt=$((attempt + 1))
done
echo "::warning::All 8 pull attempts for ${IMAGE} failed over ~340s; buildx setup will retry internally"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
# The base-image pulls (ubuntu:22.04, alpine:3.18) happen INSIDE the
# BuildKit container, which does not share the runner's local image
# cache — so the host-side pre-pull above cannot cover them, and
# they were the part of this job still exposed to unauthenticated
# Docker Hub. Docker Hub timeouts blocked PR #370 three times in one
# day, all of them here.
#
# Routing docker.io through mirror.gcr.io takes those pulls off the
# unauthenticated Docker Hub path entirely. This is a parameter of an
# action already in use, not a new dependency.
#
# Strictly additive: BuildKit falls back to the source registry when
# a mirror does not have an image or does not answer, so this cannot
# make a pull fail that would otherwise have succeeded. It also does
# not weaken the gate — the images are content-addressed, so a mirror
# cannot serve different bytes under the same digest.
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Log in to Docker Hub (optional, avoids rate limits)
# Gate on BOTH secrets: a half-configured fork (e.g. username
# set but token missing) would otherwise reach docker/login-action
# and fail noisily, defeating the "optional" intent of the step.
if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Build Ubuntu image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: docker/Dockerfile
push: false
load: true
tags: ama-cryptography:ubuntu
cache-from: type=gha,scope=ubuntu
cache-to: ${{ github.event_name == 'push' && 'type=gha,scope=ubuntu,mode=max' || '' }}
- name: Build Alpine image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: docker/Dockerfile.alpine
push: false
load: true
tags: ama-cryptography:alpine
cache-from: type=gha,scope=alpine
cache-to: ${{ github.event_name == 'push' && 'type=gha,scope=alpine,mode=max' || '' }}
- name: Test Ubuntu image
run: |
docker run --rm ama-cryptography:ubuntu python3 -c "import ama_cryptography; print('OK')"
- name: Test Alpine image
run: |
docker run --rm ama-cryptography:alpine python3 -c "import ama_cryptography; print('OK')"
ci-gate:
name: Build and Test Gate
# Single aggregating status check for this workflow (see the matching gate
# in ci.yml). `needs:` is workflow-local, so this gate covers only the jobs
# defined in ci-build-test.yml. Branch protection should require this
# context instead of the individual job names.
#
# This list must name EVERY other job in this workflow. A job absent from
# it still runs and still reports its own red X, but branch protection
# only evaluates the gate context — so an absent job cannot block a merge.
# `c-library-no-native-pqc` was exactly that: it guards the
# AMA_USE_NATIVE_PQC=OFF configuration, the build that commit f3dd0c2 on
# this branch had to repair after it broke undetected. It ran green-or-red
# into the void while the gate stayed green. tools/check_gate_coverage.py
# (INVARIANT-31) now fails the PR that omits a job here.
if: always()
needs:
- c-library
- c-library-no-native-pqc
- python-package
- lint
- security
- benchmark-regression
- docker
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. 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::Build and Test Gate failed — a required job did not succeed."
echo "Dependency results: ${{ join(needs.*.result, ', ') }}"
exit 1
- name: All required jobs succeeded
run: echo "Build and Test Gate passed — all required jobs are green."