Add Forgejo CI: ruff lint + pytest unit + gated image publish #1
@@ -0,0 +1,20 @@
|
|||||||
|
# Keep the build context lean and the image free of dev/CI cruft.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
.forgejo
|
||||||
|
.claude
|
||||||
|
tests
|
||||||
|
pyproject.toml
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.venv
|
||||||
|
README.md
|
||||||
|
summary.md
|
||||||
|
ci-requirements.md
|
||||||
|
docker-compose.yaml
|
||||||
|
docker-compose.prod.example.yaml
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Family CI lanes (FabledRulebook "CI philosophy", rule 6), mirrored from the
|
||||||
|
# NhenArchiver Python sibling and adapted for StashHandler:
|
||||||
|
# - lint: ruff only, no dep install (fast-fail for the common bounce).
|
||||||
|
# - unit: pytest, no service containers, no DB beyond SQLite tmpfiles.
|
||||||
|
# - publish: build + push the runtime image to the Forgejo registry, gated on
|
||||||
|
# the test lanes (family rule 46 tag scheme).
|
||||||
|
#
|
||||||
|
# No integration lane: StashHandler is SQLite-only with no service deps
|
||||||
|
# (family rules 79-82 do not apply).
|
||||||
|
#
|
||||||
|
# Requires repo Actions secret REGISTRY_TOKEN -- a Forgejo PAT scoped
|
||||||
|
# read:package + write:package. The injected GITHUB_TOKEN CANNOT be used: it
|
||||||
|
# lacks write:package and `docker push` 401s with reqPackageAccess (rule 7).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
# A v* tag publishes an immutable per-version image (e.g. :v26.06.26) and
|
||||||
|
# refreshes :latest (family rule 46).
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Fast-fail lint lane. ruff is pre-installed in ci-python, so this runs with
|
||||||
|
# NO dependency install and surfaces the common bounce class in seconds.
|
||||||
|
lint:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
unit:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Python deps
|
||||||
|
# uv: faster wheel resolve than pip on cold caches. Falls back to pip on
|
||||||
|
# uv-missing runners (older images). blake3 is the optional runtime hash
|
||||||
|
# backend exercised by the hasher tests.
|
||||||
|
run: |
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
uv pip install --system -r requirements.txt pytest blake3
|
||||||
|
else
|
||||||
|
pip install -r requirements.txt pytest blake3
|
||||||
|
fi
|
||||||
|
- name: Pytest (unit)
|
||||||
|
run: pytest tests/ -v
|
||||||
|
|
||||||
|
# Build + push the runtime image. Gated on the test lanes. ci-python ships the
|
||||||
|
# docker CLI + buildx; act_runner auto-mounts the host docker socket via
|
||||||
|
# valid_volumes, so we do NOT set container.options (family rule 8).
|
||||||
|
publish:
|
||||||
|
needs: [lint, unit]
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Determine tags
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
# Three trigger shapes (family rule 46 -- commit-SHA images are the
|
||||||
|
# rollback unit; :latest tracks main's tip; no separate :main tag):
|
||||||
|
# refs/tags/v* -> immutable :<version> + refresh :latest
|
||||||
|
# refs/heads/main -> :latest + :c-<short_sha>
|
||||||
|
# refs/heads/dev -> :dev + :c-<short_sha>
|
||||||
|
# POSIX-safe substring (runner shell is dash/BusyBox sh, not bash --
|
||||||
|
# ${var:0:7} errors with "Bad substitution"; cut works everywhere).
|
||||||
|
IMG=git.fabledsword.com/bvandeusen/stashhandler
|
||||||
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "tags=${IMG}:${TAG_NAME},${IMG}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
|
echo "tags=${IMG}:latest,${IMG}:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tags=${IMG}:dev,${IMG}:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login to Forgejo registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.fabledsword.com
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.tag.outputs.tags }}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# CI Requirements — StashHandler
|
||||||
|
|
||||||
|
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||||
|
> in the CI-Runner repo.
|
||||||
|
|
||||||
|
## Runtime image
|
||||||
|
|
||||||
|
The runner image this project's CI consumes via `container.image`.
|
||||||
|
|
||||||
|
```
|
||||||
|
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
```
|
||||||
|
|
||||||
|
CI lints and tests under 3.14; the **shipped** runtime is Python 3.12 (the app
|
||||||
|
`Dockerfile` is `FROM python:3.12-slim`). The code targets 3.12+ features only
|
||||||
|
(walrus, `datetime.now(timezone.utc)`), so the version gap is intentional and
|
||||||
|
harmless.
|
||||||
|
|
||||||
|
## Image deps used
|
||||||
|
|
||||||
|
Tools/libraries already in the image that this project actually relies on:
|
||||||
|
|
||||||
|
- python 3.14 (test interpreter)
|
||||||
|
- ruff (lint lane — runs with no per-job install)
|
||||||
|
- docker CLI + buildx (used by the `publish` job's build-push step)
|
||||||
|
|
||||||
|
## Per-job tool installs
|
||||||
|
|
||||||
|
Anything CI installs *at job time* (not in the image):
|
||||||
|
|
||||||
|
- `pip`/`uv install -r requirements.txt pytest blake3` in the **unit** job.
|
||||||
|
- `pytest` — test runner (not in the image).
|
||||||
|
- `blake3` — optional runtime hash backend (`HASH_ALGORITHM=blake3`),
|
||||||
|
exercised by the hasher tests. Not in `requirements.txt` because the
|
||||||
|
default `sha256` path uses stdlib `hashlib`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Friction, expectations, and anything the maintainer wouldn't see otherwise:
|
||||||
|
|
||||||
|
- **No integration lane.** StashHandler persists to SQLite (per-run tmpfiles in
|
||||||
|
tests) and talks to qBittorrent/Stash over HTTP at runtime only — there is no
|
||||||
|
service container to stand up, so family rules 79–82 don't apply here.
|
||||||
|
- **Three lanes:** `lint` (ruff) → `unit` (pytest) → `publish` (gated image
|
||||||
|
build+push, family rule 46 tag scheme). Publish image is
|
||||||
|
`git.fabledsword.com/bvandeusen/stashhandler` (lowercase — matches the prod
|
||||||
|
compose pull).
|
||||||
|
- Unit suite is a starter set covering the pure logic (hashing, SQLite dedup,
|
||||||
|
media-extension filter, file transfer modes). Grow it as behavior is added.
|
||||||
|
- Requires repo Actions secret **`REGISTRY_TOKEN`** (Forgejo PAT,
|
||||||
|
`read:package` + `write:package`) for the publish lane — the injected
|
||||||
|
`GITHUB_TOKEN` can't push to the registry (family rule 7).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
# Match the runtime image (Dockerfile FROM python:3.12-slim); CI lints under
|
||||||
|
# ci-python:3.14 but the shipped target is 3.12.
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
# Tests live in tests/ but import the app modules from the repo root
|
||||||
|
# (db, hasher, qbit_client, stash_handler). pythonpath makes the root
|
||||||
|
# importable without packaging the app.
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
def make_conn(tmp_path):
|
||||||
|
return db.init_db(str(tmp_path / "seen.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_and_is_known(tmp_path):
|
||||||
|
conn = make_conn(tmp_path)
|
||||||
|
assert not db.is_known(conn, "deadbeef")
|
||||||
|
db.record_file(conn, "deadbeef", "a.mp4", "scan")
|
||||||
|
assert db.is_known(conn, "deadbeef")
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_file_is_idempotent_on_hash(tmp_path):
|
||||||
|
conn = make_conn(tmp_path)
|
||||||
|
db.record_file(conn, "h1", "first.mp4", "scan")
|
||||||
|
db.record_file(conn, "h1", "second.mp4", "scan") # INSERT OR IGNORE
|
||||||
|
row = conn.execute("SELECT filename FROM seen_files WHERE hash='h1'").fetchone()
|
||||||
|
assert row[0] == "first.mp4" # first write wins, no duplicate row
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_seen(tmp_path):
|
||||||
|
conn = make_conn(tmp_path)
|
||||||
|
db.record_file(conn, "h1", "a.mp4", "scan") # times_seen defaults to 1
|
||||||
|
db.increment_seen(conn, "h1")
|
||||||
|
db.increment_seen(conn, "h1")
|
||||||
|
row = conn.execute("SELECT times_seen FROM seen_files WHERE hash='h1'").fetchone()
|
||||||
|
assert row[0] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_processed_torrent_roundtrip(tmp_path):
|
||||||
|
conn = make_conn(tmp_path)
|
||||||
|
assert not db.is_torrent_processed(conn, "ih1")
|
||||||
|
db.record_torrent(conn, "ih1", "Some.Torrent.Name")
|
||||||
|
assert db.is_torrent_processed(conn, "ih1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_processed_file_is_scoped_to_torrent_and_name(tmp_path):
|
||||||
|
conn = make_conn(tmp_path)
|
||||||
|
assert not db.is_file_processed(conn, "ih1", "f.mp4")
|
||||||
|
db.record_processed_file(conn, "ih1", "f.mp4")
|
||||||
|
assert db.is_file_processed(conn, "ih1", "f.mp4")
|
||||||
|
# A different file under the same torrent is tracked independently.
|
||||||
|
assert not db.is_file_processed(conn, "ih1", "other.mp4")
|
||||||
|
# Same file name under a different torrent is also independent.
|
||||||
|
assert not db.is_file_processed(conn, "ih2", "f.mp4")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import builtins
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hasher import hash_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_sha256_matches_hashlib(tmp_path):
|
||||||
|
p = tmp_path / "f.bin"
|
||||||
|
# Larger than the 64KB read chunk so the streaming loop iterates.
|
||||||
|
data = b"hello world\n" * 10000
|
||||||
|
p.write_bytes(data)
|
||||||
|
assert hash_file(p, "sha256") == hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_algorithm_is_sha256(tmp_path):
|
||||||
|
p = tmp_path / "f.bin"
|
||||||
|
p.write_bytes(b"abc")
|
||||||
|
assert hash_file(p) == hashlib.sha256(b"abc").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blake3_produces_expected_digest(tmp_path):
|
||||||
|
blake3 = pytest.importorskip("blake3")
|
||||||
|
p = tmp_path / "f.bin"
|
||||||
|
p.write_bytes(b"abc")
|
||||||
|
assert hash_file(p, "blake3") == blake3.blake3(b"abc").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blake3_missing_raises_importerror(tmp_path, monkeypatch):
|
||||||
|
p = tmp_path / "f.bin"
|
||||||
|
p.write_bytes(b"abc")
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == "blake3":
|
||||||
|
raise ImportError("simulated missing blake3")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
hash_file(p, "blake3")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import stash_handler
|
||||||
|
|
||||||
|
EXTS = {".mp4", ".mkv"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_media_is_case_insensitive():
|
||||||
|
assert stash_handler.is_media(Path("a.mp4"), EXTS)
|
||||||
|
assert stash_handler.is_media(Path("A.MKV"), EXTS)
|
||||||
|
assert not stash_handler.is_media(Path("notes.txt"), EXTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_media_recurses_and_filters(tmp_path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "a.mp4").write_bytes(b"x")
|
||||||
|
(tmp_path / "sub" / "b.mkv").write_bytes(b"x")
|
||||||
|
(tmp_path / "c.txt").write_bytes(b"x")
|
||||||
|
found = {p.name for p in stash_handler.walk_media(tmp_path, EXTS)}
|
||||||
|
assert found == {"a.mp4", "b.mkv"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_copy_preserves_original(tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"data")
|
||||||
|
dst = tmp_path / "out" / "src.mp4"
|
||||||
|
stash_handler.transfer_file(src, dst, "copy")
|
||||||
|
assert dst.read_bytes() == b"data"
|
||||||
|
assert src.exists() # seeding can continue
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_hardlink_preserves_original(tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"data")
|
||||||
|
dst = tmp_path / "out" / "src.mp4"
|
||||||
|
stash_handler.transfer_file(src, dst, "hardlink")
|
||||||
|
assert dst.read_bytes() == b"data"
|
||||||
|
assert src.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_move_removes_original(tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"data")
|
||||||
|
dst = tmp_path / "out" / "src.mp4"
|
||||||
|
stash_handler.transfer_file(src, dst, "move")
|
||||||
|
assert dst.read_bytes() == b"data"
|
||||||
|
assert not src.exists()
|
||||||
Reference in New Issue
Block a user