From 18a346c95f1df970483818c581ffc071bedc0abe Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 18 Oct 2026 18:17:47 -0700 Subject: [PATCH 1/4] style: satisfy the unicorn and ruff rules the toolchain bump added Pre-existing on develop, where pushes do not run CI: eslint-plugin-unicorn prefer-ternary and no-immediate-mutation in three frontend files, and ruff ICN001 (`import datetime as dt`) in four Python files. Behavior unchanged; `make lint` could not reach the shell, Dockerfile and workflow checks until these passed. Co-Authored-By: Claude Fable 5.1 --- codex/migrations/0001_init.py | 4 ++-- codex/migrations/0007_auto_20211210_1710.py | 4 ++-- codex/user_data/restore.py | 6 ++---- frontend/src/api/v4/browser.js | 7 +++---- frontend/src/stores/admin.js | 9 +++++---- frontend/src/stores/reader.js | 3 +-- tests/test_models.py | 4 ++-- 7 files changed, 17 insertions(+), 20 deletions(-) diff --git a/codex/migrations/0001_init.py b/codex/migrations/0001_init.py index 979c1fb59..51eb61ad1 100644 --- a/codex/migrations/0001_init.py +++ b/codex/migrations/0001_init.py @@ -1,6 +1,6 @@ """Generated by Django 3.1 on 2020-08-21 18:59.""" -import datetime +import datetime as dt from decimal import Decimal import django.db.models.deletion @@ -88,7 +88,7 @@ class Migration(migrations.Migration): ("enable_scan_cron", models.BooleanField(db_index=True, default=True)), ( "scan_frequency", - models.DurationField(default=datetime.timedelta(seconds=43200)), + models.DurationField(default=dt.timedelta(seconds=43200)), ), ("last_scan", models.DateTimeField(null=True)), ("scan_in_progress", models.BooleanField(default=False)), diff --git a/codex/migrations/0007_auto_20211210_1710.py b/codex/migrations/0007_auto_20211210_1710.py index 2a637e2c4..108057dd9 100644 --- a/codex/migrations/0007_auto_20211210_1710.py +++ b/codex/migrations/0007_auto_20211210_1710.py @@ -1,6 +1,6 @@ """Generated by Django 3.2.9 on 2021-12-11 01:10.""" -import datetime +import datetime as dt import django.db.models.deletion from django.db import migrations, models @@ -111,7 +111,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="library", name="poll_every", - field=models.DurationField(default=datetime.timedelta(seconds=3600)), + field=models.DurationField(default=dt.timedelta(seconds=3600)), ), migrations.AlterField( model_name="comic", diff --git a/codex/user_data/restore.py b/codex/user_data/restore.py index 6d99b1755..afb3caec6 100644 --- a/codex/user_data/restore.py +++ b/codex/user_data/restore.py @@ -270,7 +270,7 @@ def _restore_libraries( store: SidecarStore, report: RestoreReport, *, dry_run: bool ) -> None: """Restore Library rows.""" - import datetime + import datetime as dt from codex.models.library import Library @@ -285,9 +285,7 @@ def _restore_libraries( } if row["poll_every"]: # Stored as total-seconds float by ``serialize_library``. - defaults["poll_every"] = datetime.timedelta( - seconds=float(row["poll_every"]) - ) + defaults["poll_every"] = dt.timedelta(seconds=float(row["poll_every"])) Library.objects.update_or_create(path=row["path"], defaults=defaults) report.note_written("libraries") diff --git a/frontend/src/api/v4/browser.js b/frontend/src/api/v4/browser.js index ad36f07aa..96995b5af 100644 --- a/frontend/src/api/v4/browser.js +++ b/frontend/src/api/v4/browser.js @@ -55,13 +55,12 @@ export const getBrowserHref = ({ collection, pks, query }) => { export const getCoverSrc = ({ coverPk, coverCustomPk }, ts) => { const query = ts ? `?ts=${ts}` : ""; - if (coverCustomPk) { - return `${V4_BASE}covers/custom/${coverCustomPk}${query}`; - } // ``coverPk`` is the representative comic pk pre-annotated by the // browser response. v4 keeps the per-comic source so the URL is // cacheable across cards that share a representative. - return `${V4_BASE}covers/comic/${coverPk}${query}`; + return coverCustomPk + ? `${V4_BASE}covers/custom/${coverCustomPk}${query}` + : `${V4_BASE}covers/comic/${coverPk}${query}`; }; const PLACEHOLDER_BY_GROUP = Object.freeze({ diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index f790ba68e..5f98ce15f 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -170,7 +170,6 @@ export const useAdminStore = defineStore("admin", { } }, async loadTables(tables, options) { - if (this._requireAdmin()) return false; /* * ``Promise.all`` so every fetch runs concurrently and the * returned promise resolves only once they've all settled. @@ -179,9 +178,11 @@ export const useAdminStore = defineStore("admin", { * could observe an admin tab's state mid-load (some tables * populated, some still empty), which presented as flicker. */ - return await Promise.all( - tables.map((table) => this.loadTable(table, options)), - ); + return this._requireAdmin() + ? false + : await Promise.all( + tables.map((table) => this.loadTable(table, options)), + ); }, async loadFolders(path, showHidden) { if (this._requireAdmin()) return false; diff --git a/frontend/src/stores/reader.js b/frontend/src/stores/reader.js index 272242ba3..75ba1d68c 100644 --- a/frontend/src/stores/reader.js +++ b/frontend/src/stores/reader.js @@ -371,8 +371,7 @@ export const useReaderStore = defineStore("reader", { }, fitToClass(bookSettings) { const classes = {}; - let fitTo; - fitTo = + const fitTo = this.clientSettings.scale > SCALE_DEFAULT ? FIT_TO_CLASSES.O : FIT_TO_CLASSES[bookSettings.fitTo]; diff --git a/tests/test_models.py b/tests/test_models.py index d2f47b763..d2f7019dd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,6 @@ """Test models.""" -import datetime +import datetime as dt import shutil from typing import override @@ -40,7 +40,7 @@ class ComicTestCase(TestCase): YEAR = 1975 MONTH = 4 DAY = 9 - DATE = datetime.date(YEAR, MONTH, DAY) + DATE = dt.date(YEAR, MONTH, DAY) @override def setUp(self): From 28f7d29ed7b2ae339a883dfe0e71d3acc6392f96 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 18 Oct 2026 18:17:47 -0700 Subject: [PATCH 2/4] ci: install the lint tools in the codex-ci stage; update devenv to 0.2.9 devenv 0.2.9 makes a missing lint tool an error in CI instead of a skip, so the codex-ci stage now copies shellcheck, shfmt, hadolint, actionlint and dockerfmt from their upstream images and unpacks shellharden's latest release, as devenv's README prescribes. apt's shellcheck goes, so the image runs the same version as the dev machines. The devenv update brings the `need` guard in bin/_lib.sh, forwards CI into the compose service, and adds the devenv-lint-tools action and the native-mode install step. Co-Authored-By: Claude Fable 5.1 --- .devenv-version | 2 +- .github/actions/devenv-lint-tools/action.yml | 27 ++++++++++++++++++++ .github/workflows/devenv-check.yml | 16 +++++++++--- Dockerfile | 19 +++++++------- bin/_lib.sh | 7 ++++- compose.yaml | 1 + 6 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 .github/actions/devenv-lint-tools/action.yml diff --git a/.devenv-version b/.devenv-version index a45be4627..1866a362b 100644 --- a/.devenv-version +++ b/.devenv-version @@ -1 +1 @@ -0.2.8 +0.2.9 diff --git a/.github/actions/devenv-lint-tools/action.yml b/.github/actions/devenv-lint-tools/action.yml new file mode 100644 index 000000000..8e199a2e2 --- /dev/null +++ b/.github/actions/devenv-lint-tools/action.yml @@ -0,0 +1,27 @@ +# Managed by devenv (copy/ci). Edit it there, not in a child repo. +name: devenv lint tools +description: >- + Install the lint tools devenv's bin/ scripts run with Homebrew and put them + on PATH for later steps. The latest of each, as on the dev machines. Works + on ubuntu runners, where Homebrew is installed but not on PATH, and on macOS. +inputs: + tools: + description: Space-separated Homebrew formulae to install. + default: shellcheck shellharden shfmt hadolint dockerfmt actionlint +runs: + using: composite + steps: + - name: Install lint tools with Homebrew + shell: bash + env: + TOOLS: ${{ inputs.tools }} + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + HOMEBREW_NO_ANALYTICS: "1" + run: | + if [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + fi + read -ra formulae <<<"$TOOLS" + brew install "${formulae[@]}" + echo "$(brew --prefix)/bin" >>"$GITHUB_PATH" diff --git a/.github/workflows/devenv-check.yml b/.github/workflows/devenv-check.yml index 1bad0e684..42e2c0c1b 100644 --- a/.github/workflows/devenv-check.yml +++ b/.github/workflows/devenv-check.yml @@ -2,9 +2,10 @@ # # Gate, one CI image build, a fail-fast lint/test/build matrix that pulls that # image, and the required-check aggregator. With container: false the matrix -# runs natively on inputs.runner instead (for macOS-only projects) and no -# image is built. Call it from a job in ci.yml; later jobs read its outputs -# instead of repeating any trigger logic: +# runs natively on inputs.runner instead (for macOS-only projects), no image +# is built and the lint combo installs the lint tools with brew. Call it from +# a job in ci.yml; later jobs read its outputs instead of repeating any +# trigger logic: # # ci: # uses: ./.github/workflows/devenv-check.yml @@ -36,7 +37,8 @@ on: description: >- true runs every combo in the CI image built from the Dockerfile. false runs it natively on the runner after setup-uv, setup-bun and - make install, for projects that cannot run in a Linux container. + make install, for projects that cannot run in a Linux container; a + combo that runs lint installs the lint tools with brew first. type: boolean default: true runner: @@ -296,6 +298,12 @@ jobs: if: ${{ !inputs.container }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # Only the combo that lints pays for the install. In container mode the + # CI stage of the project's Dockerfile has the tools. + - name: Install lint tools + if: ${{ !inputs.container && contains(matrix.make, 'lint') }} + uses: ./.github/actions/devenv-lint-tools + - name: Install if: ${{ !inputs.container }} run: | diff --git a/Dockerfile b/Dockerfile index 9a1005a2d..b135496db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,17 +40,18 @@ RUN pip3 install --no-cache --upgrade pip FROM oven/bun:latest AS bun-source FROM builder-base AS codex-ci -# hadolint ignore=DL3008 -RUN apt-get clean \ - && apt-get update \ - && apt-get install --no-install-recommends -y \ - shellcheck \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - COPY --from=bun-source /usr/local/bin/bun /usr/local/bin/bun COPY --from=bun-source /usr/local/bin/bunx /usr/local/bin/bunx +# Lint tools devenv's bin/ scripts run, unpinned on purpose. +COPY --from=koalaman/shellcheck:stable /bin/shellcheck /usr/local/bin/ +COPY --from=mvdan/shfmt:latest /bin/shfmt /usr/local/bin/ +COPY --from=hadolint/hadolint:latest /bin/hadolint /usr/local/bin/ +COPY --from=rhysd/actionlint:latest /usr/local/bin/actionlint /usr/local/bin/ +COPY --from=ghcr.io/reteps/dockerfmt:latest /ko-app/dockerfmt /usr/local/bin/ +ADD https://github.com/anordal/shellharden/releases/latest/download/shellharden-x86_64-unknown-linux-musl.tar.gz /tmp/shellharden.tar.gz +RUN tar -xzf /tmp/shellharden.tar.gz -C /usr/local/bin shellharden && rm /tmp/shellharden.tar.gz + WORKDIR /app # Python deps (cacheable when lockfiles unchanged) @@ -143,4 +144,4 @@ RUN python -B -c "import django, cryptography, granian, rapidfuzz" \ VOLUME /comics VOLUME /config EXPOSE 9810 -CMD ["/opt/codex/bin/codex"] +CMD ["/opt/codex/bin/codex"] \ No newline at end of file diff --git a/bin/_lib.sh b/bin/_lib.sh index e281ddd6c..098d86697 100644 --- a/bin/_lib.sh +++ b/bin/_lib.sh @@ -17,11 +17,16 @@ is_darwin() { } # Succeed when the tool is on PATH. Otherwise say so and how to install it, and -# fail, so a caller can skip that tool visibly instead of silently. +# fail, so a caller can skip that tool visibly instead of silently. In CI (CI +# set, as GitHub sets it) a missing tool is an error instead, so a green CI +# run means every tool ran. need() { if command -v "$1" >/dev/null 2>&1; then return 0 fi + if [[ -n ${CI:-} ]]; then + die "$1 not installed. CI must install it: brew install $1, or add it to the Dockerfile's CI stage" + fi echo "skipped: $1 not installed (brew install $1)" >&2 return 1 } diff --git a/compose.yaml b/compose.yaml index bfdb90b7c..552166153 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,7 @@ services: - infinity environment: - PYTHONUNBUFFERED=1 + - CI image: ${CI_IMAGE} volumes: - ./test-results:/app/test-results From 671b7cdbc3ebbbb538edd4d17eeeffe4a81d5e22 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 18 Oct 2026 18:29:09 -0700 Subject: [PATCH 3/4] build: keep the two directory symlinks out of the sdist uv's sdist builder reads docs/NEWS/codex and docs/img, symlinks to directories, as files and fails with "Is a directory". Pre-existing on develop, where pushes do not run CI; it broke the Build Dist job. The site builds from the checkout, so the sdist does not need them. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5fbc1d14e..daf1baa33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,9 @@ source-include = [ "docs/**", ] source-exclude = [ + # Symlinks to directories, which the sdist builder reads as files. + "docs/NEWS/codex", + "docs/img", "**/*\\~", "**/.DS_Store", "codex/static_build", From 57512c01b6241022c33fe17d60623cd7594cea36 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 18 Oct 2026 18:37:01 -0700 Subject: [PATCH 4/4] update deps --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index b893ac9f3..4a9f31255 100644 --- a/uv.lock +++ b/uv.lock @@ -2908,11 +2908,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.13.4" +version = "4.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/89/dfd269de936e76319cf27787d9eaaca513491f635d49b0fbe7cdb6d3bf6f/platformdirs-4.13.4.tar.gz", hash = "sha256:dd96496961684a7f42041923f842ec7494e09f5e87aded99117e1109cd40570e", size = 62364, upload-time = "2026-10-17T19:31:53.69Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/51/5614d0537dd830ca64d95a2f254bd66bdf3ac61d41cffcccaec62121fcfd/platformdirs-4.13.4-py3-none-any.whl", hash = "sha256:502ce08e9d36cf90579ee44e1e764838842fa658e4975ef47344fd053011d2c8", size = 32782, upload-time = "2026-10-17T19:31:52.293Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, ] [[package]]