Skip to content

test: tie SECURITY.md's deploy-hook claim to the control that enforces it #1127

test: tie SECURITY.md's deploy-hook claim to the control that enforces it

test: tie SECURITY.md's deploy-hook claim to the control that enforces it #1127

Workflow file for this run

name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
workflow_dispatch:
schedule:
# Mondays 06:00 UTC, for the ca-endpoints job. The other jobs opt out of
# this trigger explicitly (`if: github.event_name != 'schedule'`) — saying
# so in a comment did not make it so, and a weekly full run would turn a
# CA-reachability signal into noise from whatever else happened to break
# that week (Copilot, #538).
- cron: '0 6 * * 1'
# Default to read-only. The single job below only reads source and uploads
# coverage via codecov-action (which manages its own auth via the upload
# token, not the GITHUB_TOKEN), so no job needs write here.
permissions: read-all
# Per-ref: a newer push to a branch supersedes that branch's own older
# in-flight run. This does NOT cover the shared-container clobber across
# DIFFERENT refs (two open PRs) — that is handled by a host-global group on
# the `test` job below.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
if: github.event_name != 'schedule'
# GitHub-hosted, ephemeral VM per job. The e2e suite (tests/conftest.py)
# builds and runs a FIXED-name container (certmate-test-suite) on port
# 18888; on a shared self-hosted host, two test jobs from different
# refs/PRs clobbered each other's container mid-run, which forced a
# host-global concurrency group that serialized every test job one at a
# time (a single-point bottleneck: a burst of PRs + Dependabot builds
# queued for 10-20 min behind one box). On ubuntu-latest every job gets
# its own VM and localhost, so there is no cross-ref collision and no need
# to serialize. Docker is preinstalled, so the container e2e tests still
# run. The per-ref concurrency group at the top of the file still cancels
# a branch's own superseded runs.
runs-on: ubuntu-latest
strategy:
matrix:
# 3.12 matches the production Dockerfile. Python 3.14 is temporarily
# out of the matrix: the current pin of josepy 1.13.0 (required by
# certbot 2.10.0 → acme 3.3.0) raises ValueError at class definition
# under PEP 649 evaluation. Tracked in #103 — restore when the
# certbot/josepy/acme stack is upgraded.
python-version: ['3.12']
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5 (was v4)
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 (was v3)
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Lint with flake8
run: |
# Pinned so the build pulls a known version (OpenSSF Scorecard
# Pinned-Dependencies flags bare, unversioned pip installs).
pip install flake8==7.3.0
# stop the build if there are Python syntax errors or undefined names
# Fails the build. E9/F63/F7/F82 are syntax errors and undefined
# names; the rest are bug classes that are already clean in this
# codebase and must stay that way — F811 (a redefinition that silently
# shadows an import), F632 (is-comparison against a literal), E711/E712
# (== None / == True), E713/E714 (not ... in / not ... is). #428
flake8 . --count --select=E9,F63,F7,F82,F811,F632,E711,E712,E713,E714 --show-source --statistics
# exit-zero treats all errors as warnings
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Security check with bandit
run: |
pip install bandit==1.9.4
bandit -r modules/ app.py --severity-level medium
- name: Run tests with coverage
env:
FLASK_ENV: testing
TESTING: true
run: |
# Measure the application package (modules/), not the whole repo: a
# bare --cov=. folds the tests/ and scripts/ trees (which run at ~100%)
# into the number, inflating it and hiding the real app coverage.
# --cov-fail-under is a FLOOR, not a target (#428): before it, project
# and patch statuses were both informational and fail_ci_if_error was
# false, so coverage could fall to zero with CI still green. 65 is
# comfortably below the current number; raise it as a ratchet, never
# lower it to make a red build pass.
# `not network`: the CA-reachability checks in
# tests/test_ca_endpoints_are_live.py fetch five external ACME
# directories. They found two real defects (a DigiCert host that no
# longer resolves, a Google staging endpoint whose certificate is for
# another name) — but as a required check they would turn every PR red
# on someone else's outage. They run on the weekly schedule below
# instead, where a failure is news rather than a blocked merge.
pytest -v --tb=short --cov=modules --cov-report=xml --cov-report=html --cov-fail-under=65 -m "not ui and not network"
- name: Upload coverage reports
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v5 (was v3)
with:
# Required since main is a protected branch — without a token Codecov
# rejects the upload ("Token required because branch is protected") and
# the badge silently freezes at a stale value. fail_ci_if_error stays
# false so a transient Codecov outage never blocks a release.
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
# Build AND boot both requirements sets.
#
# This step used to run `docker build` alone. A successful build proves the
# layers assemble, not that the process starts — and `requirements-minimal
# .txt`, which the Dockerfile advertises via REQUIREMENTS_FILE, was never
# built by anything at all. It had been missing SQLAlchemy for months:
# factory.py imports APScheduler's SQLAlchemyJobStore at module scope, so
# gunicorn's worker died on import and the container restart-looped (#514).
# It took a user building it to find out.
#
# So: every advertised requirements file gets built, started, and asked for
# /health. A container that cannot answer that has not been tested.
- name: Build and boot every advertised image variant
if: matrix.python-version == '3.12'
run: |
set -euo pipefail
for req in requirements.txt requirements-minimal.txt; do
tag="certmate:test-$(basename "$req" .txt)"
echo "::group::build $req"
docker build --build-arg REQUIREMENTS_FILE="$req" -t "$tag" .
echo "::endgroup::"
echo "::group::boot $req"
cid=$(docker run -d -p 18900:8000 "$tag")
ok=0
for _ in $(seq 1 30); do
if curl -fsS http://localhost:18900/health >/dev/null 2>&1; then ok=1; break; fi
# Fail fast if the container is already gone: waiting out the
# timeout on a dead container just hides the traceback.
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then break; fi
sleep 2
done
if [ "$ok" != 1 ]; then
echo "::error::$req produced an image that never served /health"
docker logs "$cid" 2>&1 | tail -40
docker rm -f "$cid" >/dev/null 2>&1 || true
exit 1
fi
echo "$req -> /health OK"
# /health says nothing about certbot, and CertMate drives it as a
# subprocess. pip's metadata used to refuse any cryptography new
# enough to pull a pyOpenSSL that breaks the ACME stack; as of
# 2026-08-08 it no longer does, so a bump can install cleanly, boot
# cleanly, and fail on the first issuance. Ask the binary directly.
if ! docker exec "$cid" certbot --version; then
echo "::error::$req built an image whose certbot cannot start — no certificate can be issued. See SECURITY.md 'Known dependency constraint'."
docker rm -f "$cid" >/dev/null 2>&1 || true
exit 1
fi
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "::endgroup::"
done
# The optional requirements files are never built above, because no image
# ships them — and that is exactly why they rotted unnoticed:
#
# * requirements-extended.txt could not be installed at all, in any
# environment. certbot-dns-powerdns==0.2.1 wants dns-lexicon<=3.5.6 and
# certbot-dns-linode wants >=3.14.1: ResolutionImpossible, alone in an
# empty venv or layered either way. Advertised by the Dockerfile as an
# EXTRA_REQUIREMENTS option the whole time.
# * requirements-aws.txt and -gcp.txt pinned plugins above the certbot
# pin. Since extras install in a *separate* pip run, pip upgraded
# certbot off 2.10.0 — measured at 3.3.0 on 3.12 — in a build that
# reported success.
#
# Resolving is enough to catch both and costs seconds, so every file and
# every combination the Dockerfile and docker-compose.yml advertise gets
# resolved on every run. `--dry-run` does full dependency resolution
# without downloading wheels.
- name: Resolve every optional requirements combination
if: matrix.python-version == '3.12'
run: |
set -euo pipefail
combos=(
"requirements-minimal.txt requirements-extended.txt"
"requirements.txt requirements-aws.txt"
"requirements.txt requirements-gcp.txt"
"requirements.txt requirements-azure.txt"
"requirements.txt requirements-aws.txt requirements-gcp.txt"
)
pinned=$(grep -oE '^certbot==[0-9.]+' requirements.txt | cut -d= -f3)
echo "certbot is pinned at $pinned"
for combo in "${combos[@]}"; do
echo "::group::resolve $combo"
args=()
for f in $combo; do args+=(-r "$f"); done
# Resolved together, not one after another. The Dockerfile installs
# extras in a separate pip run, where a plugin requiring a newer
# certbot is silently granted it; asking pip to satisfy every file at
# once turns that same silent upgrade into ResolutionImpossible,
# which is the answer we want on a build machine. Both failure modes
# are covered: this catches the conflict, the version check below
# catches a drift pip could satisfy.
report=$(mktemp)
# --ignore-installed is what makes this a check. The job already has
# requirements.txt installed, so without it pip reports only the
# delta — four packages, certbot not among them — and any assertion
# about certbot's resolved version passes without having looked at
# one. Verified: the report goes from 4 entries to the full set.
if ! python -m pip install --dry-run --ignore-installed --quiet \
--report "$report" "${args[@]}"; then
echo "::error::$combo does not resolve — this combination is advertised and cannot be installed"
exit 1
fi
# One line on purpose: a multi-line `python -c "..."` inside a YAML
# block scalar has to start at column 0 to be valid Python, and that
# is not valid YAML. The default is 'ABSENT', never the pinned
# version — a resolution that does not mention certbot has not
# checked certbot, and must not read as a pass.
got=$(python -c "import json; d=json.load(open('$report')); print(next((i.get('metadata',{}).get('version','UNKNOWN') for i in d.get('install',[]) if i.get('metadata',{}).get('name','').lower()=='certbot'), 'ABSENT'))")
if [ "$got" != "$pinned" ]; then
echo "::error::$combo resolves certbot to $got, not the pinned $pinned. An optional file is dragging the ACME stack off its pin (#103 is still a plan, not a migration)."
exit 1
fi
echo "$combo -> certbot $got"
echo "::endgroup::"
done
# Every CA in the registry, asked whether its ACME directory still exists.
#
# `digicert` pointed at acme.digicert.com for months after DigiCert retired
# CertCentral's legacy ACME service on 24 February 2026 — the hostname stopped
# resolving and nothing said so, because a CA endpoint is only exercised
# during a real issuance against that CA, and the release gate issues against
# Let's Encrypt staging. Weekly is the right cadence: a CA does not retire an
# endpoint between two pull requests, and this must never be the reason a
# merge is blocked.
ca-endpoints:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Ask every CA whether its directory still exists
run: pytest tests/test_ca_endpoints_are_live.py -v --tb=short -m network
# The wiki is a separate git repository, so nothing in CI has ever looked at
# it — and it has now drifted three times, each time by keeping a second copy
# of something the repo had already corrected. The last sweep found four
# claims fixed in docs/ months earlier: Python 3.9, a DigiCert host that
# stopped resolving in February, an endpoint that returns 404, and an
# environment variable read by nothing.
#
# Weekly and not required, for the same reason as ca-endpoints: the wiki is
# not part of the merge decision, and a clone failing should never block a
# pull request. A finding here is news, not a broken build.
wiki:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5
with:
python-version: '3.12'
- name: Clone the wiki
run: git clone --depth 1 https://github.com/${{ github.repository }}.wiki.git wiki
- name: Check the wiki against the repository
run: python scripts/check_wiki.py wiki
# The Tailwind bundle (static/css/tailwind.min.css) is committed to the repo
# and served as-is — there is no build step at deploy time. So a stale bundle
# ships silently whenever input.css / tailwind.config.js change without a
# rebuild. This job rebuilds from source and fails if the committed output
# drifts, forcing `npm run css:build` to be part of every CSS-touching PR.
# The MCP server ships in the image and is documented as a supported way to
# drive CertMate from an assistant, but its two test suites were run by
# nothing: no workflow, no Makefile target, not run-tests.sh, not release.sh.
# Dependabot DOES update mcp/ dependencies (.github/dependabot.yml), so those
# bumps were shipping with a suite that never executed — and the async
# regression that made the documented job-polling loop impossible sat there
# undetected because nothing asked.
mcp:
if: github.event_name != 'schedule'
name: MCP server
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
with:
node-version: '20'
- name: Install MCP dependencies
working-directory: mcp
run: npm ci
- name: Run the MCP suites
working-directory: mcp
run: npm test
frontend-css:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
with:
node-version: '20'
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: Rebuild CSS bundle
run: npm run css:build
- name: Fail if committed bundle is stale
run: |
if ! git diff --exit-code -- static/css/tailwind.min.css; then
echo "::error::static/css/tailwind.min.css is out of date. Run 'npm run css:build' and commit the result."
exit 1
fi
# Theme-token regression gate: the migration (docs/THEME_MIGRATION.md)
# collapsed every light + dark: color pair onto semantic tokens. This fails
# the build if a new raw pair (e.g. bg-white dark:bg-gray-800) is
# reintroduced in a template or first-party JS file, keeping the theme
# editable from one place.
- name: Theme token regression gate
run: python3 scripts/theme_codemod.py --check