# .gitea/workflows/ci.yml # # PassKeeper CI pipeline — runs on every push and pull request. # Designed for a self-hosted host-mode runner (no Docker required). # Requires on the host: python3, pip3, node, zip # # Jobs: # lint-python — flake8 style + error check # syntax-check — ast.parse all Python files # migration-check — verify Alembic chain has single head # js-syntax — node syntax check on all JS files + PSL matching tests # tests — pytest suite (in-memory SQLite, no MySQL needed) # build-extension — zip Chrome and Firefox extensions name: CI on: push: branches: ["main", "master", "dev"] pull_request: branches: ["main", "master"] jobs: # ── Python lint ────────────────────────────────────────────────────────────── lint-python: name: Python lint (flake8) runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install flake8 run: pip3 install flake8 --quiet --break-system-packages - name: Run flake8 run: | python3 -m flake8 app/ \ --max-line-length=120 \ --extend-ignore=E501,W503,E302,E303,E305,W292,E131,E401,E711,E712,F401,F811 \ --exclude=__pycache__,migrations \ --statistics # ── Python syntax ──────────────────────────────────────────────────────────── syntax-check: name: Python syntax check runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Check all Python files parse cleanly run: | python3 - << 'EOF' import ast, sys, pathlib, itertools # Root-level modules (wsgi, run, reset_db, gunicorn.conf) were not # covered before, so a syntax error in the Gunicorn config or the WSGI # entrypoint reached production without CI noticing. paths = list(itertools.chain( pathlib.Path('app').rglob('*.py'), pathlib.Path('tests').rglob('*.py'), pathlib.Path('scripts').rglob('*.py'), pathlib.Path('migrations/versions').rglob('*.py'), pathlib.Path('.').glob('*.py'), )) failures = [] for path in paths: try: ast.parse(path.read_text(encoding='utf-8')) except SyntaxError as e: failures.append(f"{path}: {e}") for f in failures: print(f"FAIL: {f}") if failures: sys.exit(1) print(f"OK: {len(paths)} Python files parsed cleanly") EOF # ── Alembic migration chain ────────────────────────────────────────────────── migration-check: name: Alembic migration chain runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Verify single head, no duplicate revisions run: | python3 - << 'EOF' import re, sys, glob files = glob.glob('migrations/versions/*.py') revisions = {} for f in files: content = open(f).read() rev = re.search(r"revision = '([^']+)'", content) down = re.search(r"down_revision = (.+)", content) if rev: rid = rev.group(1) if rid in revisions: print(f"FAIL: Duplicate revision ID {rid} in {f}") sys.exit(1) revisions[rid] = down.group(1).strip() if down else 'None' all_downs = set(revisions.values()) heads = [r for r in revisions if repr(r) not in all_downs and r not in all_downs] if len(heads) != 1: print(f"FAIL: Expected 1 head, found {len(heads)}: {heads}") sys.exit(1) print(f"OK: {len(revisions)} migrations, single head: {heads[0]}") EOF # ── JS syntax check ────────────────────────────────────────────────────────── js-syntax: name: JavaScript syntax check runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Check JS files parse cleanly run: | FAILED=0 for f in \ app/static/js/vault.js \ app/static/js/auth.js \ app/static/js/crypto.js \ app/static/js/sharing.js \ app/static/js/recover.js \ extension/popup/popup.js \ extension/background.js \ extension/background.firefox.js \ extension/content/content.js \ extension/bridge/bridge.js \ extension/shared/crypto.js \n extension/shared/psl.js; do if [ -f "$f" ]; then node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \ { echo "FAIL: $f"; FAILED=1; } fi done [ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly" exit $FAILED - name: PSL matching tests # Guards the autofill same-site check. A wrong answer here means # credentials offered on an attacker's neighbouring subdomain. run: node tests/js/test_psl.js # ── Test suite ─────────────────────────────────────────────────────────────── # Runs against in-memory SQLite (see app/config.py TestingConfig) so no MySQL # service is needed on the host-mode runner. That means these tests cover # application logic and flow, not MySQL-specific behaviour — schema changes # still need a real `flask db upgrade` against MySQL before deploying. tests: name: Pytest runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install dependencies run: pip3 install -r requirements.txt -r requirements-dev.txt --quiet --break-system-packages - name: Run test suite run: python3 -m pytest tests/ -q # ── Extension build ────────────────────────────────────────────────────────── build-extension: name: Build extension zip runs-on: ubuntu-latest needs: [syntax-check, js-syntax, tests] steps: - uses: actions/checkout@v3 - name: Build Chrome/Edge extension run: | cd extension zip -r ../passkeeper-extension-chrome.zip . \ --exclude "*.bak" \ --exclude "manifest.firefox.json" \ --exclude "background.firefox.js" echo "Chrome: $(du -sh ../passkeeper-extension-chrome.zip | cut -f1)" - name: Build Firefox extension run: | cd extension cp manifest.json manifest.chrome.json cp manifest.firefox.json manifest.json zip -r ../passkeeper-extension-firefox.zip . \ --exclude "*.bak" \ --exclude "manifest.chrome.json" \ --exclude "background.js" mv manifest.chrome.json manifest.json echo "Firefox: $(du -sh ../passkeeper-extension-firefox.zip | cut -f1)" - name: Upload Chrome extension artifact uses: actions/upload-artifact@v3 with: name: passkeeper-extension-chrome path: passkeeper-extension-chrome.zip retention-days: 30 - name: Upload Firefox extension artifact uses: actions/upload-artifact@v3 with: name: passkeeper-extension-firefox path: passkeeper-extension-firefox.zip retention-days: 30