ci: gate Docker Hub steps on env, not secrets (unavailable in step if) #8
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 | |
| # Runs the project's existing self-contained test suites — no live server, | |
| # no API keys, no network calls to an LLM. Compatible with both GitHub | |
| # Actions and Gitea Actions (same workflow syntax). | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| jobs: | |
| backend: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # actions/setup-python only ships prebuilt interpreters for GitHub's | |
| # hosted Ubuntu images; in a minimal Gitea job container it fails with | |
| # "version '3.11' ... was not found for this operating system". Use the | |
| # platform's own Python instead: already present on GitHub runners, | |
| # apt-installed in the container otherwise. Both paths give Python 3.11. | |
| - name: Set up Python | |
| run: | | |
| if ! command -v python3 >/dev/null 2>&1; then | |
| apt-get update && apt-get install -y --no-install-recommends python3 python3-pip | |
| fi | |
| # node:*-bookworm ships python3 but no pip module — ensure pip too. | |
| if ! python3 -m pip --version >/dev/null 2>&1; then | |
| if command -v apt-get >/dev/null 2>&1; then | |
| apt-get update && apt-get install -y --no-install-recommends python3-pip | |
| else | |
| python3 -m ensurepip --upgrade | |
| fi | |
| fi | |
| python3 --version && python3 -m pip --version | |
| - name: Install dependencies | |
| # Debian's system Python is PEP 668 "externally managed"; the flag is | |
| # safe in this ephemeral CI env and is supported by modern pip on both. | |
| run: python3 -m pip install --break-system-packages -r requirements.txt | |
| - name: Audit Python deps | |
| # pip-audit catches CVEs in pinned transitive deps. The default | |
| # pip-audit behavior (no flags) creates an ephemeral venv via | |
| # ensurepip to resolve each requirement — and that fails on minimal | |
| # Gitea job containers that don't have python3-venv / ensurepip. | |
| # We avoid that by: | |
| # 1) generating a *pinned* requirements file with pip-compile | |
| # (from pip-tools; one extra dep, but deterministic & fast) | |
| # 2) installing requirements into the current env so pip-audit | |
| # can read installed versions directly | |
| # 3) running pip-audit with --disable-pip --no-deps so it scans | |
| # the current env's installed packages instead of spinning up | |
| # another venv | |
| # --strict requires pinned (==) versions; --disable-pip requires | |
| # either --no-deps or --require-hashes; both are satisfied here. | |
| run: | | |
| set -e | |
| python3 -m pip install --break-system-packages 'pip-audit==2.10.1' 'pip-tools==7.5.3' | |
| python3 -m pip install --break-system-packages -r requirements.txt | |
| # Pin transitive deps for audit. --quiet keeps the log readable. | |
| python3 -m piptools compile --quiet --output-file=requirements-pinned.txt --no-header --strip-extras requirements.txt | |
| python3 -m pip_audit -r requirements-pinned.txt --strict --disable-pip --no-deps | |
| - run: python3 -m py_compile server.py translator.py cache.py | |
| - run: python3 test_translation.py | |
| - run: python3 test_hardening.py | |
| frontend: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| - run: node -c static/translator.js | |
| - run: node -c static/loader.js | |
| - run: npm install | |
| - run: npm audit --omit=dev | |
| - run: npm test | |
| # Smoke test: build the image, run it, hit /ping, expect 200. | |
| # Catches Dockerfile regressions (syntax, missing deps, broken entrypoint, | |
| # USER appuser) that pure Python tests can't. Skips when Docker isn't | |
| # available — some Gitea job containers ship without dind, and we'd | |
| # rather get a green run with a "skipped" note than a hard fail that | |
| # blocks legitimate PRs. The pure-Python backend suite above already | |
| # exercises the application code on a mock LLM, so losing this smoke | |
| # step in those environments doesn't drop coverage to zero. | |
| docker-smoke: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Detect Docker | |
| id: docker | |
| run: | | |
| if command -v docker >/dev/null 2>&1; then | |
| echo "available=true" >> "$GITHUB_OUTPUT" | |
| docker --version | |
| else | |
| echo "available=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::docker not installed in runner; skipping docker-smoke job" | |
| fi | |
| - name: Build image | |
| if: steps.docker.outputs.available == 'true' | |
| run: docker build -t bt-audit:ci . | |
| - name: Run container | |
| if: steps.docker.outputs.available == 'true' | |
| run: | | |
| docker run -d --name bt-smoke -p 8390:8390 bt-audit:ci | |
| # Wait for /ping to come up (the image has start-period=10s in its | |
| # HEALTHCHECK, but we don't want to depend on Docker's healthcheck | |
| # state for the smoke test — poll the endpoint directly). | |
| for i in $(seq 1 30); do | |
| if curl -sf http://127.0.0.1:8390/ping >/dev/null 2>&1; then | |
| echo "ping ok on try $i" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| # The actual assertion: /ping returns 200 with {"status":"ok"}. | |
| curl -sf http://127.0.0.1:8390/ping | grep -q '"status":"ok"' | |
| - name: Verify non-root API process | |
| # The /ping endpoint serves from gunicorn. The `appuser` should own | |
| # the gunicorn process; the entrypoint (and nginx if it were started) | |
| # can stay root. | |
| if: steps.docker.outputs.available == 'true' | |
| run: | | |
| # BusyBox `ps` (Alpine) has no -p flag — list user+args and grep the | |
| # gunicorn master instead. [g]unicorn avoids matching the grep itself. | |
| USER=$(docker exec bt-smoke ps -o user,args | grep '[g]unicorn' | head -1 | awk '{print $1}') | |
| if [ "$USER" = "appuser" ]; then | |
| echo "gunicorn runs as appuser (unprivileged) — OK" | |
| else | |
| echo "gunicorn runs as '$USER', expected appuser" | |
| docker exec bt-smoke ps -o user,args | head -10 | |
| exit 1 | |
| fi | |
| - name: Cleanup | |
| if: always() && steps.docker.outputs.available == 'true' | |
| run: docker rm -f bt-smoke 2>/dev/null || true |