diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 79b6b85..407dc24 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -2,7 +2,17 @@ name: Build images on: push: - branches: [dev, main] + # `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after + # merge-to-main, not from the dev branch image. Saves one full docker + # build per dev push. + branches: [main] + # Tag-push triggers an immutable per-version image build (e.g. + # `:v26.05.26.5`) — gives a real rollback story alongside the floating + # `:main` / `:latest`. Layer reuse keeps the registry-storage cost + # negligible per tag. Doesn't overlap with the push-to-main build (that + # one publishes `:main` + `:latest`; the tag-push build publishes only + # `:`). + tags: ['v*'] # Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes: # - write:package, read:package (for docker push to git.fabledsword.com) @@ -158,8 +168,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Download signed XPI from Forgejo release asset (main only) - if: github.ref == 'refs/heads/main' + - name: Download signed XPI from Forgejo release asset (main + tags) + # Fires on main-push AND on tag-push. Tag-push builds re-package the + # same source code as the preceding main-push build but with an + # immutable version tag — they need the XPI too, otherwise the + # versioned image ships without the signed extension. + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | @@ -200,7 +214,20 @@ jobs: - name: Determine tag id: tag run: | - if [ "${GITHUB_REF##*/}" = "main" ]; then + # Three trigger shapes: + # refs/tags/v… → tag-push: publish ONLY the immutable version + # tag (e.g. :v26.05.26.5). Don't touch :latest; + # that already got published by the main-push + # build for the merge commit. + # refs/heads/main → push to main (incl. PR merge commits): + # publish :main + :latest (floating). + # anything else → safety net; shouldn't fire given the `on:` + # config above (dev was dropped). Tag :dev to + # surface the unexpected run in the registry. + if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then + TAG_NAME="${GITHUB_REF#refs/tags/}" + echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" @@ -231,7 +258,13 @@ jobs: - name: Determine tag id: tag run: | - if [ "${GITHUB_REF##*/}" = "main" ]; then + # Mirrors build-web's three-shape logic (tag-push / main-push / + # safety-net dev). The -ml image follows the same release cadence + # as the web image. + if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then + TAG_NAME="${GITHUB_REF#refs/tags/}" + echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 46db216..45b3571 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -8,8 +8,10 @@ name: CI on: push: branches: [dev, main] - pull_request: - branches: [main] + # pull_request trigger intentionally absent — with branches: [dev, main] + # above, every PR commit already fires CI via the push event on dev. Adding + # pull_request would duplicate runs on dev→main PRs. FC has no fork PRs + # (single-operator Forgejo repo) so push coverage is complete. jobs: backend-lint-and-test: diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3298a3c..70efbda 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,13 +3,23 @@ import logging from pathlib import Path -from quart import Quart +from quart import Quart, request from .api import all_blueprints from .config import get_config from .frontend import frontend_bp from .services.credential_crypto import CredentialCrypto +# Browser-extension origins. The FabledCurator extension fetches from +# moz-extension:/// on Firefox and chrome-extension:/// on +# Chromium-based browsers. Operator-flagged 2026-05-26: extension's +# 'Test connection' returned `NetworkError` because the X-Extension-Key +# header on /api/credentials triggers a CORS preflight that our routes +# don't handle. Whitelisting only these two schemes (not opening CORS +# up generally) lets the extension talk to a plain-HTTP self-hosted FC +# without weakening the no-CORS posture for normal browser usage. +_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://") + _CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64") @@ -35,6 +45,33 @@ def create_app() -> Quart: # Registered last so /api/* routes win over the SPA catch-all. app.register_blueprint(frontend_bp) + @app.before_request + async def _extension_cors_preflight(): + # Short-circuit OPTIONS preflight from the browser extension with a + # 204 + CORS headers (the after_request hook below adds them). + # Without this, OPTIONS lands on routes that only declared POST/GET + # methods and 405s before the after_request gets a chance. + if request.method != "OPTIONS": + return None + origin = request.headers.get("Origin", "") + if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): + return "", 204 + return None + + @app.after_request + async def _extension_cors_headers(response): + origin = request.headers.get("Origin", "") + if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PATCH, DELETE, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, X-Extension-Key" + ) + response.headers["Access-Control-Max-Age"] = "86400" + return response + @app.after_serving async def _dispose_db_engine() -> None: from .extensions import dispose_engine diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue index 8b39cca..93f4062 100644 --- a/frontend/src/components/artist/ArtistHeader.vue +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -25,6 +25,11 @@ Management + + +
@@ -54,11 +59,13 @@ const stats = computed(() => {