CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
212 lines
8.1 KiB
YAML
212 lines
8.1 KiB
YAML
# .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
|
|
|
|
- name: Field heuristic tests
|
|
# Guards login-field detection. A wrong answer here means autofill
|
|
# silently does nothing on real login pages.
|
|
run: node tests/js/test_field_heuristics.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 |