Compare commits

...

7 Commits

Author SHA1 Message Date
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac39509a74 fix(ci): rename shard jobs to no-separator names (intapi/intimp/intcore) + add diagnostic docker ps dump so next bounce surfaces the real act_runner naming convention — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 20:59:17 -04:00
bvandeusen 3531f373ee feat(settings): move BrowserExtensionCard from Maintenance to Overview tab — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 20:33:44 -04:00
bvandeusen 36cc0622cb fix(importer): sanitize PostAttachment.ext to skip mangled gallery-dl URL-encoded basenames (varchar(32) overrun) — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 20:33:44 -04:00
bvandeusen e50f92d900 perf(ci): shard integration suite into 3 parallel jobs (int_api, int_imp, int_core) — newly feasible after act_runner capacity 2→6 — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 20:05:15 -04:00
bvandeusen ba8d9b112d fix(ext-ci): add diagnostic tracing to commit step to surface why run #309 reported success without producing the XPI side-commit — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 20:05:15 -04:00
bvandeusen c451061ca5 fix(ext-ci): self-retrigger workflow on its own edits (path filter includes workflow file) — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> 2026-05-25 18:24:31 -04:00
6 changed files with 259 additions and 37 deletions
+168 -22
View File
@@ -72,17 +72,28 @@ jobs:
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
integration: # Integration suite split into THREE parallel shards (2026-05-25, runner
# This act_runner (swarm-runner v0.6.1) puts service containers on the # capacity bumped 2→6). Each shard gets its own Postgres + Redis service
# default bridge with NO service-name DNS, and publishing fixed host # set and runs alembic + a disjoint subset of integration tests. Shards
# ports collides with the operator's running docker-compose dev stack on # share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
# the same shared daemon. Workaround: publish NO host ports, and reach # stays single-threaded per shard but multiple shards run in parallel
# each service by its bridge IP — discovered at runtime via the mounted # wall-clock. Approximate split — rebalance once --durations=15 output
# docker socket (the ci-python image ships /usr/bin/docker). Default-bridge # reveals which shard is the long pole.
# containers can talk by IP (only embedded DNS is missing), so IP #
# addressing is reliable here. Everything runs in ONE step so resolved # Each shard's docker-ps filter uses its own unique job name to scope
# values don't depend on cross-step env passing. Pattern documented in # service-container resolution. act_runner appears to strip underscores
# FabledRulebook/forgejo.md "CI philosophy". # from job names when building container labels — `int_api` yielded
# zero matches on 2026-05-25 — so shards use no-separator names
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
# `docker ps -a` first so a future naming-convention shift surfaces in
# the log without another guess-and-push cycle.
#
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
# done — per ci-requirements.md, FC is the only Python consumer of that
# image and the CI-Runner project's "add deps to image when used by >1
# project" rule keeps the install per-job.
intapi:
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -113,7 +124,6 @@ jobs:
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Cache pip wheels - name: Cache pip wheels
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -121,15 +131,14 @@ jobs:
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }} key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: | restore-keys: |
pip-${{ runner.os }}-py314- pip-${{ runner.os }}-py314-
- name: API integration shard (resolve service IPs, migrate, test)
- name: Integration suite (resolve service IPs, migrate, test)
run: | run: |
set -eux set -eux
# Scope to THIS job's service containers (act_runner names them echo "=== container landscape (diagnostic for filter scoping) ==="
# ...JOB-integration...); the operator's compose stack uses the docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
# same images but different names, so it won't match. echo "=== end landscape ==="
PG=$(docker ps --filter "name=JOB-integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1) PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=JOB-integration" --filter "ancestor=redis:7-alpine" -q | head -n1) RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD" test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD") RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
@@ -137,16 +146,153 @@ jobs:
export DB_HOST="$PG_IP" export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0" export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0" export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
# Wait for Postgres to accept TCP (bash /dev/tcp; no extra tools).
for i in $(seq 1 60); do for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break (echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2 sleep 2
done done
# uv when available (5-10x faster wheel resolve); fall back to pip.
if command -v uv >/dev/null 2>&1; then if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio uv pip install --system -r requirements.txt pytest pytest-asyncio
else else
pip install -r requirements.txt pytest pytest-asyncio pip install -r requirements.txt pytest pytest-asyncio
fi fi
alembic upgrade head alembic upgrade head
pytest tests/ -v -m integration --durations=25 pytest tests/test_api_*.py -v -m integration --durations=15
intimp:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Importer integration shard (resolve service IPs, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
alembic upgrade head
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
intcore:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
alembic upgrade head
pytest tests/ -v -m integration --durations=15 \
--ignore-glob='tests/test_api_*.py' \
--ignore-glob='tests/test_importer*.py' \
--ignore-glob='tests/test_import_*.py' \
--ignore-glob='tests/test_migration_*.py' \
--ignore-glob='tests/test_phash_*.py' \
--ignore-glob='tests/test_sidecar_*.py' \
--ignore-glob='tests/test_scan_*.py' \
--ignore-glob='tests/test_archive_extractor.py' \
--ignore-glob='tests/test_backfill_phash.py'
+37 -12
View File
@@ -2,7 +2,14 @@ name: extension
on: on:
push: push:
branches: [dev, main] branches: [dev, main]
paths: ['extension/**'] # Self-retriggering: include the workflow file itself so edits to
# this workflow (filename-glob fixes, env tweaks, etc.) run the
# sign-and-publish dance on next push without needing a manual
# Forgejo dispatch. Side-effect commits (`ext: publish signed XPI`)
# only touch frontend/public/extension/ so they DON'T re-trigger.
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -38,34 +45,52 @@ jobs:
WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}
- name: Commit signed XPI to frontend/public/extension/ - name: Commit signed XPI to frontend/public/extension/
run: | run: |
set -e set -ex
# AMO renames signed XPIs using its internal addon-id-safe-string # AMO renames signed XPIs using its internal addon-id-safe-string
# (e.g. 997017ca3e104e30a75a-1.0.1.xpi), NOT our gecko ID. The # (e.g. 997017ca3e104e30a75a-1.0.1.xpi), NOT our gecko ID. Match
# original 'fabledcurator-*' glob never matched and the step # any *.xpi in the artifacts dir — fresh CI runner means there's
# exited 1 even on a successful sign (operator-flagged # exactly one — and rename it on copy so the FC server's
# 2026-05-25). Match any *.xpi in the artifacts dir — fresh # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi')
# CI runner means there's exactly one — and rename it on copy # keeps working.
# so the FC server's whitelist (backend/app/frontend.py expects # Diagnostic instrumentation 2026-05-25: a prior run (id 1731)
# 'fabledcurator-*.xpi') keeps working. # reported success without producing an `ext: publish signed XPI`
# commit on main. Tracing the cwd, artifacts dir, version
# extraction, staging state, and push response so the next run's
# log explains the gap.
echo "=== cwd ==="
pwd
echo "=== ref / branch ==="
echo "ref: ${GITHUB_REF:-unset} sha: ${GITHUB_SHA:-unset}"
git log --oneline -3 || true
echo "=== artifacts dir ==="
ls -la extension/web-ext-artifacts/ 2>&1 || echo "(dir missing)"
XPI=$(ls extension/web-ext-artifacts/*.xpi 2>/dev/null | head -1) XPI=$(ls extension/web-ext-artifacts/*.xpi 2>/dev/null | head -1)
echo "XPI=$XPI"
if [ -z "$XPI" ]; then if [ -z "$XPI" ]; then
echo "No XPI produced by web-ext sign — exiting" echo "No XPI produced by web-ext sign — exiting"
ls -la extension/web-ext-artifacts/ || true
exit 1 exit 1
fi fi
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
echo "VERSION=$VERSION"
DEST="frontend/public/extension/fabledcurator-${VERSION}.xpi" DEST="frontend/public/extension/fabledcurator-${VERSION}.xpi"
echo "DEST=$DEST"
mkdir -p frontend/public/extension mkdir -p frontend/public/extension
# Wipe any prior versions so the directory doesn't grow each release. # Wipe any prior versions so the directory doesn't grow each release.
rm -f frontend/public/extension/fabledcurator-*.xpi rm -f frontend/public/extension/fabledcurator-*.xpi
cp "$XPI" "$DEST" cp "$XPI" "$DEST"
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi" cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
echo "=== frontend/public/extension/ after copy ==="
ls -la frontend/public/extension/
git config user.name "FC extension CI" git config user.name "FC extension CI"
git config user.email "noreply@fabledsword.com" git config user.email "noreply@fabledsword.com"
git add frontend/public/extension/ git add frontend/public/extension/
echo "=== git status after add ==="
git status --short
echo "=== diff --cached --stat ==="
git diff --cached --stat || true
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit (frontend/public/extension/ matches HEAD)"
else else
git commit -m "ext: publish signed XPI fabledcurator-${VERSION}.xpi" git commit -m "ext: publish signed XPI fabledcurator-${VERSION}.xpi"
git push origin HEAD:main git push -v origin HEAD:main
fi fi
+26 -1
View File
@@ -71,6 +71,31 @@ def is_video(path: Path) -> bool:
return path.suffix.lower() in VIDEO_EXTS return path.suffix.lower() in VIDEO_EXTS
def _safe_ext(path: Path) -> str:
"""Conservatively extract a file extension for PostAttachment.ext
(varchar(32)).
gallery-dl produces some filenames with URL-encoded query-string
artifacts embedded into the basename (e.g.
`79507046_media_..._https___www.patreon.com_media-u_Z0FBQUFBQm5q...`).
`Path.suffix` finds the LAST dot and returns everything after, which
in those cases yields a 50+ char "extension" of mostly base64-ish
junk. That blows the column. Operator-flagged 2026-05-25.
Real extensions are short and alphanumeric. We accept anything ≤ 16
chars where every post-dot character is alphanumeric; anything else
means the input wasn't a real extension and we return the empty
string. ext is nullable-ish (empty string still satisfies NOT NULL)
and consumers should treat "" as "no known extension".
"""
suffix = path.suffix.lower()
if not suffix or len(suffix) > 16:
return ""
if not all(c.isalnum() for c in suffix[1:]):
return ""
return suffix
def _mime_for(path: Path) -> str: def _mime_for(path: Path) -> str:
suffix = path.suffix.lower() suffix = path.suffix.lower()
image_mimes = { image_mimes = {
@@ -209,7 +234,7 @@ class Importer:
sha256=sha, sha256=sha,
path=stored, path=stored,
original_filename=source.name, original_filename=source.name,
ext=source.suffix.lower(), ext=_safe_ext(source),
mime=_mime_for(source), mime=_mime_for(source),
size_bytes=source.stat().st_size, size_bytes=source.stat().st_size,
)) ))
@@ -14,7 +14,6 @@
<AliasTable class="mt-4" /> <AliasTable class="mt-4" />
<BackupCard class="mt-6" /> <BackupCard class="mt-6" />
<TagMaintenanceCard class="mt-6" /> <TagMaintenanceCard class="mt-6" />
<BrowserExtensionCard class="mt-6" />
<LegacyMigrationCard class="mt-6" /> <LegacyMigrationCard class="mt-6" />
</div> </div>
</template> </template>
@@ -27,7 +26,6 @@ import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
import TagMaintenanceCard from './TagMaintenanceCard.vue' import TagMaintenanceCard from './TagMaintenanceCard.vue'
import BrowserExtensionCard from './BrowserExtensionCard.vue'
import LegacyMigrationCard from './LegacyMigrationCard.vue' import LegacyMigrationCard from './LegacyMigrationCard.vue'
</script> </script>
+6
View File
@@ -31,6 +31,11 @@
{{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending. {{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending.
<v-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn> <v-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn>
</v-alert> </v-alert>
<!-- Browser-extension install/download lives on Overview (moved
from Maintenance 2026-05-25). Overview is the discovery
surface for "things to set up"; Maintenance is for
housekeeping of already-set-up systems. -->
<BrowserExtensionCard class="mt-6" />
</v-window-item> </v-window-item>
<v-window-item value="activity"> <v-window-item value="activity">
@@ -62,6 +67,7 @@ import { useImportStore } from '../stores/import.js'
import SystemStatsCards from '../components/settings/SystemStatsCards.vue' import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue' import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
import SystemActivityTab from '../components/settings/SystemActivityTab.vue' import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
import BrowserExtensionCard from '../components/settings/BrowserExtensionCard.vue'
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue' import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue' import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
import ImportTaskList from '../components/settings/ImportTaskList.vue' import ImportTaskList from '../components/settings/ImportTaskList.vue'
+22
View File
@@ -59,3 +59,25 @@ def test_json_sidecar_is_not_attached(importer, import_layout):
select(func.count()).select_from(PostAttachment) select(func.count()).select_from(PostAttachment)
).scalar_one() ).scalar_one()
assert n == 0 assert n == 0
def test_mangled_filename_extension_is_sanitized(importer, import_layout):
"""gallery-dl sometimes URL-encodes a query string into the basename
(`...https___www.patreon.com_media-u_Z0F...`). Python's Path.suffix
returns 50+ chars of base64-ish junk for those, which blows the
PostAttachment.ext varchar(32) column. Operator-flagged 2026-05-25.
The importer should record an empty ext rather than crash."""
import_root, _ = import_layout
f = (
import_root / "Alice"
/ "79507046_media_https___www.patreon.com_media-u_Z0FBQUFBQm5q"
)
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"binary blob")
r = importer.import_one(f)
assert r.status == "attached"
att = importer.session.execute(select(PostAttachment)).scalar_one()
# Junk "extension" -> stored as empty string (not the 50-char garbage).
assert att.ext == ""
# original_filename is Text-typed so the full name survives intact.
assert att.original_filename.endswith("_Z0FBQUFBQm5q")