Merge pull request #183 from MrChengLen/pr-token-check #507
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"] | |
| # A newer push on the same ref cancels older queued / running runs. | |
| # Saves Actions quota and reduces queue pressure when iterating on a PR. | |
| # main / develop are excluded by ref-pattern so post-merge gates always | |
| # get to complete. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| # Least-privilege default for GITHUB_TOKEN (OpenSSF Scorecard Token-Permissions). | |
| # Both jobs only read the checkout; no job here writes back to the repo. | |
| permissions: | |
| contents: read | |
| jobs: | |
| lint-and-test: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| # Must match the Dockerfile's base image — the app is only ever | |
| # tested on the version it ships on. scripts/check_python_version.py | |
| # gates this; see its docstring for the drift it exists to prevent. | |
| python-version: "3.14" | |
| cache: "pip" | |
| - name: Install system dependencies | |
| run: | | |
| # The runner image ships an apt source for Google Chrome, which this | |
| # project does not use. Its index is intermittently inconsistent and | |
| # then fails `apt-get update` with a hash-sum mismatch (exit 100), | |
| # taking the whole job down with it — it blocked every PR on | |
| # 2026-09-09. Dropping the source first makes the update depend only | |
| # on repositories we actually install from. | |
| # | |
| # Matched by name rather than by a fixed filename: the runner images | |
| # have moved between `google-chrome.list` and the deb822 | |
| # `google-chrome.sources`, and a stale `rm -f` of the wrong one fails | |
| # silently — which is exactly how the first attempt at this slipped | |
| # through and left the job failing identically. | |
| sudo find /etc/apt/sources.list.d -iname '*google*' -delete | |
| sudo apt-get update | |
| sudo apt-get install -y ffmpeg ghostscript libheif-dev libcairo2 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 | |
| # Tests run against the versions the image ships. requirements-dev.txt | |
| # pulls in requirements.txt, whose `>=` ranges resolve to the newest | |
| # releases: SQLAlchemy 2.1.0 (2026-09-24) reached CI that way and failed a | |
| # test on every branch, while the image stayed on 2.0.52. The lockfile — | |
| # what the Dockerfile installs — therefore constrains every runtime | |
| # package. Dev-only tools (pytest, ruff, uv, aiosqlite, …) are not in it | |
| # and resolve as before; a dependency they share with the app (packaging, | |
| # requests, …) stays at its locked version. The unpinned install runs | |
| # weekly in deps-latest.yml as an early warning. | |
| # | |
| # The hashes are stripped because pip switches the whole install to | |
| # --require-hashes as soon as one constraint carries a hash, and the dev | |
| # tools are unhashed. The step fails if a lockfile entry did not become a | |
| # constraint (or none did), instead of letting it install unpinned. | |
| # | |
| # A resolver conflict here usually means requirements.txt raised a floor | |
| # past the locked version, as Dependabot pip PRs do: lockfile-drift is red | |
| # as well, and recompiling the lockfile fixes both. If lockfile-drift is | |
| # green, a dev tool wants a different version of a locked package. If it | |
| # caps one below the pin, hold that dev-tool bump. If it needs a newer | |
| # one, which a plain recompile does not move, move it explicitly — that | |
| # changes what ships, so review it like a runtime bump (uv leaves the flag | |
| # out of the lockfile header, so lockfile-drift stays green): | |
| # uv pip compile --generate-hashes --python-version 3.14 --python-platform x86_64-unknown-linux-gnu --upgrade-package <name> --output-file requirements.lock requirements.txt | |
| - name: Install Python dependencies | |
| run: | | |
| constraints="$RUNNER_TEMP/constraints.txt" | |
| grep -E '^[A-Za-z0-9._-]+==' requirements.lock | cut -d' ' -f1 > "$constraints" | |
| if [ ! -s "$constraints" ] || grep -E '^[^[:space:]#]' requirements.lock | grep -qvE '^[A-Za-z0-9._-]+=='; then | |
| echo "::error::Not every requirements.lock entry could be turned into a constraint (see the comment above this step in ci.yml)." | |
| exit 1 | |
| fi | |
| pip install -r requirements-dev.txt -c "$constraints" | |
| - name: Lint (ruff) | |
| run: ruff check . | |
| - name: Format check (ruff) | |
| run: ruff format --check . | |
| # One Python version across image, CI, and lockfile. A Dependabot base-image | |
| # bump used to move production to a version CI never tested (2b26b49: 3.12 -> | |
| # 3.14, undetected for three months). The Dockerfile is the source of truth; | |
| # this fails if anything disagrees. See the script's docstring. | |
| - name: Python-version consistency gate | |
| run: python scripts/check_python_version.py | |
| # Tailwind dynamic-class gate — forbids `class="prefix-{{ x }}-suffix"` | |
| # patterns that JIT extraction misses, leaving the production bundle | |
| # silently incomplete. See scripts/check_template_classes.py docstring. | |
| - name: Template class gate | |
| run: python scripts/check_template_classes.py | |
| # Tailwind bundle freshness gate — rebuild the purged bundle from the | |
| # committed templates/JS and fail if it differs from the committed one. | |
| # A stale bundle silently drops every utility class added since the last | |
| # manual rebuild: from May to September 2026 about 46 classes the | |
| # templates used (grid columns, gaps, list numbering, bottom-0, …) never | |
| # rendered. The script pins the CLI version and verifies its SHA-256 | |
| # before running it. A deleted old bundle shows up as a tracked change and | |
| # a new one as untracked, so `status --porcelain` catches either; the | |
| # status runs on its own line so a git failure aborts instead of passing. | |
| # On failure the CI-built bundle is uploaded — useful when a local build | |
| # differs (Tailwind also scans untracked template files, CI never sees them). | |
| - name: Tailwind bundle freshness gate | |
| id: tailwind | |
| run: | | |
| bash scripts/build-tailwind.sh | |
| changes="$(git status --porcelain -- app/static/css/)" | |
| if [ -n "$changes" ]; then | |
| printf '%s\n' "$changes" | |
| echo "::error::The committed Tailwind bundle is stale. Run 'bash scripts/build-tailwind.sh' and commit app/static/css/ (the CI-built bundle is attached as an artifact)." | |
| exit 1 | |
| fi | |
| - name: Upload the CI-built Tailwind bundle | |
| if: failure() && steps.tailwind.outcome == 'failure' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: tailwind-bundle-ci-built | |
| path: | | |
| app/static/css/tailwind.*.css | |
| !app/static/css/tailwind.input.css | |
| retention-days: 7 | |
| # i18n drift gate — re-extract the .pot from current sources and fail | |
| # if it differs from the committed file. Catches a developer who added | |
| # `_('...')` calls but forgot `python scripts/i18n.py extract` before | |
| # pushing. Translation work then stays in sync with code. | |
| - name: i18n message-catalog drift | |
| run: python scripts/i18n.py drift-check | |
| # pip-audit scans dependencies for known CVEs. | |
| # Blocking — a failure means a published advisory affects a pinned dep. | |
| # If a finding is genuinely unfixable upstream, add the ID to the --ignore-vuln list | |
| # below with a comment naming the package + reason; never silence the whole step. | |
| # | |
| # Ignored advisories: | |
| # - PYSEC-2026-1325 (= CVE-2024-23342, GHSA-wj6h-64fc-37mp): Minerva timing | |
| # side-channel in `ecdsa`, transitive via python-jose. No fixed version | |
| # exists; upstream declares side-channel attacks out of scope, so none is | |
| # planned. Not reachable in FileMorph: JWTs are HS256-only | |
| # (app/core/tokens.py) — no ECDSA sign/keygen/ECDH path ever runs. | |
| # Added 2026-07-15; re-evaluate at each release, drop once we migrate | |
| # off python-jose (e.g. PyJWT) or upstream ships a fix. | |
| # Audits requirements.lock, not requirements.txt: the lockfile is what the | |
| # image installs (Dockerfile: pip install --require-hashes), so it is the | |
| # only file whose contents actually reach production. The lockfile-drift | |
| # job below guarantees it still corresponds to requirements.txt. | |
| # - CVE-2026-55073 (= GHSA-jf6q-chmf-3h3v), MODERATE / CVSS 6.2 (AV:L): | |
| # SSRF-protection bypass in WeasyPrint < 70.0. Two write_pdf() channels | |
| # build a fresh default URLFetcher instead of the document's one, so a | |
| # restrictive `url_fetcher` is silently ignored for `xmp_metadata=[url]` | |
| # and `stylesheets=[url]`. Not reachable in FileMorph: all four | |
| # write_pdf() call sites pass the output path only and never those | |
| # parameters (app/converters/document.py:228, 392, 426, 491), so the | |
| # advisory's third precondition -- forwarding an attacker-influenced | |
| # URL into either -- cannot occur. The fix is 70.0, which we cannot take | |
| # yet: it reworks the url_fetcher contract from a callable to an object | |
| # and would break `_deny_url_fetcher` (see the cap in requirements.txt). | |
| # Re-evaluate when the fetcher is ported; drop this ignore then. | |
| # Added 2026-09-09. | |
| - name: Dependency vulnerability scan (pip-audit) | |
| run: pip-audit -r requirements.lock --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-55073 | |
| - name: Run tests | |
| run: pytest tests/ -v --tb=short | |
| # requirements.lock is what the image installs, so it has to keep | |
| # corresponding to requirements.txt. Before this gate existed it did not: | |
| # Dependabot pushed 32 commits to requirements.txt in six months while the | |
| # lockfile got 4 (one of them only line endings), leaving it short of four | |
| # packages entirely and behind on five more. Recompiling here and diffing is | |
| # the only way to notice. | |
| # | |
| # On drift this fails and uploads the correct lockfile as an artifact — grab | |
| # it and commit it, or run the deps-lock workflow to have it committed for | |
| # you. The lockfile targets the shipped Python version (markers resolve per | |
| # version, so one resolved elsewhere can be uninstallable in the image); | |
| # `uv pip compile --python-version` resolves *for* that version without | |
| # needing it installed, which is why anyone can regenerate the lockfile from | |
| # any machine. | |
| # | |
| # requirements-sbom.lock, the SBOM generator's lockfile, is checked the same | |
| # way against requirements-sbom.txt. Dependabot only edits that .txt, so | |
| # without this a generator bump would merge green and change nothing. | |
| lockfile-drift: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| # Any interpreter would do here — uv resolves for the target version | |
| # via --python-version. Kept at the shipped version so every workflow | |
| # agrees, which the gate in lint-and-test enforces. | |
| python-version: "3.14" | |
| cache: "pip" | |
| # The exact uv requirements-dev.txt pins, read from that file: a resolver | |
| # update can legitimately produce a different lockfile, which would turn | |
| # this gate red with no dependency change behind it, so this job, | |
| # deps-lock.yml and a local recompile must run the same release. | |
| # Dependabot bumps only requirements-dev.txt; a version typed in here was | |
| # left behind twice. The assignment is deliberate: under `bash -e` it | |
| # stops the step when the pin is missing, whereas pip takes an empty | |
| # argument as nothing to install and exits 0. The pattern admits only a | |
| # version, and --only-binary keeps build code out of deps-lock's job, | |
| # which can push. | |
| - name: Install uv | |
| run: | | |
| uv_pin=$(grep -oE '^uv==[0-9][0-9A-Za-z.!+-]*' requirements-dev.txt) | |
| pip install --only-binary :all: "$uv_pin" | |
| # The command must match the one recorded in the committed lockfile's | |
| # header verbatim, since uv writes that command into the file. uv also | |
| # reads the existing output file as a preference, so unchanged | |
| # dependencies keep their pins and a new upstream release does not turn | |
| # this gate red on its own. | |
| # | |
| # --python-platform matters as much as --python-version: uvicorn[standard] | |
| # pulls uvloop on Linux and not on Windows, so a lockfile resolved on a | |
| # developer's Windows box is missing a package the image needs. Pinning | |
| # the platform makes the result identical wherever it is generated. This | |
| # gate caught exactly that on its first run. | |
| - name: Recompile the lockfiles | |
| run: | | |
| cp requirements.lock requirements.lock.committed | |
| uv pip compile --generate-hashes --python-version 3.14 --python-platform x86_64-unknown-linux-gnu --output-file requirements.lock requirements.txt | |
| cp requirements-sbom.lock requirements-sbom.lock.committed | |
| uv pip compile --generate-hashes --python-version 3.14 --python-platform x86_64-unknown-linux-gnu --output-file requirements-sbom.lock requirements-sbom.txt | |
| - name: Compare | |
| id: compare | |
| run: | | |
| drifted="" | |
| diff -u requirements.lock.committed requirements.lock || drifted="$drifted requirements.lock" | |
| diff -u requirements-sbom.lock.committed requirements-sbom.lock || drifted="$drifted requirements-sbom.lock" | |
| if [ -z "$drifted" ]; then | |
| echo "requirements.lock and requirements-sbom.lock are in sync with their .txt files" | |
| else | |
| echo "::error::Out of sync with its .txt file:$drifted. Download the 'requirements-lock-recompiled' artifact and commit the file(s) named here, or run the deps-lock workflow." | |
| exit 1 | |
| fi | |
| - name: Upload the recompiled lockfiles | |
| if: failure() && steps.compare.outcome == 'failure' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: requirements-lock-recompiled | |
| path: | | |
| requirements.lock | |
| requirements-sbom.lock | |
| retention-days: 7 | |
| secret-scan: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: gitleaks (server-side secret scanner) | |
| uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |