test: tie SECURITY.md's deploy-hook claim to the control that enforces it #1127
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 | |
| 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 |