The concurrency group only deduplicates live work on a branch; a run that was queued or still building kept its runner after the branch was deleted or its pull request closed. A delete and a pull_request closed trigger now cancel everything of that branch that has not completed, minus the cancelling run itself, which shares the head branch on a pull request event.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every push started its own run, so two pushes to the same branch raced through the same jobs and burned runner minutes twice. One concurrency group per ref now covers all six jobs, including the reusable workflows. Pull request runs cancel their predecessor; branch pushes queue instead, so a release push is never cancelled between the per-architecture push and the manifest merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SHA-pinned actions, the two base images, the compose file, the Python project and the npm assets had no update stream, so a pinned reference only moved when someone noticed it by hand. Adds a daily Dependabot config for all five.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The publish job built a single-arch image on ubuntu-latest, so every release manifest carried linux/amd64 only. Split it into a version job that exports the semver tag and image name, a matrix publish job that builds each architecture on its own native runner (ubuntu-latest, ubuntu-24.04-arm) and pushes an arch-suffixed tag, and a manifest job that joins both under the release tag. Native runners avoid the QEMU emulation layer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recalcWhileCollapsing ran a requestAnimationFrame loop and cancelled it on
the header's max-height transitionend. When no max-height transition ran,
for example because the header was already in its target state, that event
never fired and the loop recalculated the scroll container on every frame
for the rest of the page's life; each further call started another such
loop. The loop now continues only while header.getAnimations() reports a
running animation.
The resize handler entered or exited fullscreen on every resize event. It
now returns early when the UI fullscreen state already matches the body
class, so a resize that changes nothing starts no recalc loop.
A Cypress spec spies on adjustScrollContainerHeight after exitFullscreen()
and requires the call count to stop growing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
observeIframeNavigation read iframe.contentWindow.location.href once,
outside any guard. For a cross-origin iframe that read throws a
SecurityError, which escaped as an uncaught exception and never reached
the polling loop, whose own read of the same property is already guarded.
The first read is now guarded as well, and the observer returns because it
cannot follow a cross-origin frame anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handler assigned through an optional chain,
this.nextElementSibling?.style.display='inline-block'. An optional chain
is not a valid assignment target, so the whole attribute failed to compile
("SyntaxError: Invalid left-hand side in assignment") whenever an icon
image failed to load: the broken image stayed visible and the fallback
<i> icon never appeared. An explicit null check does the same and
compiles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
base.html.j2 set window.I18N from an executable inline <script>. A host
CSP can only allow an inline script by hash or by 'unsafe-inline', and this
script's content changes with every language, so no hash can cover it.
Infinito.Nexus serves the dashboard with a hash-based script-src-elem
whenever its logout feature is off, and there the script was blocked:
every page logged "Executing inline script violates the following Content
Security Policy directive 'script-src-elem ...'" and window.I18N stayed
undefined.
The strings now ship as <script id="i18n" type="application/json">, which
the browser does not execute and CSP does not govern; modal.js parses the
block before its first use. tojson escapes <, > and &, so a string that
contains "</script>" cannot end the block early.
Integration tests require that a page ships no executable inline script
and that a catalogue string containing "</script>" survives the round trip
through the block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The language switcher offers all 184 ISO 639-1 languages, but only 29
had a UI catalogue, so the other 154 rendered Close, Imprint, Language
and the rest of the interface strings in English. Nothing noticed:
the existing unit test only checks the catalogues that are present.
A new integration test requires every language except the source to
ship a catalogue with a non-empty entry for each of the 11 interface
strings, and lists missing catalogues apart from incomplete ones. The
154 missing catalogues are added, written through
i18n_sync.write_catalog so they share the existing format; make i18n-ui
never overwrites them, so a later correction survives.
The translations were written without a native reviewer. Most widely
used languages are solid; these are best-effort and likely wrong:
ae, cr, ii, kr, na, nv, oj, sg, aa, hz, ho, mh, za, vo. Uncertain:
ab, ak, av, ay, bi, bm, bo, ce, ch, cu, dv, dz, ee, ff, fj, gn, gv, ie,
ik, iu, kg, ki, kj, kl, ks, kv, kw, lg, li, ln, lu, ng, nr, os, pi, qu,
rn, sa, se, ss, to, tw, ty, ve, wa, wo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Starting app on host:port" line sat at module level, so every
import printed it, including the test suites and any WSGI server that
loads the module; CodeQL reported it as a print during import. It now
lives in the __main__ block, which the Docker CMD and the e2e runner
reach through python app.py; the e2e Flask log still shows the line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL flagged docker/setup-buildx-action, docker/login-action,
docker/build-push-action and cypress-io/github-action as unpinned: a
moving v3 or v6 tag lets whoever controls it change what runs with the
workflow's GHCR write token.
Each action is now pinned to the commit its tag points to today, with
the exact release as a trailing comment (v3.12.0, v3.7.0, v6.19.2,
v6.10.9), resolved through the GitHub API, so the code that runs does
not change. GitHub's own actions/* were not flagged and keep their tags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
catalog() joined the requested language code straight into the UI and
content catalogue paths, and read_catalog logged those paths. Both the
negotiated Accept-Language code and the /<lang>/ route only ever pass
supported codes, but that guarantee lived in the callers, so CodeQL
reported path injection and log injection on the request value.
catalog() now resolves the code through a table of the supported
languages and builds the file names from the table's value, so an
unsupported code returns an empty catalogue and never becomes a path.
A unit test holds "../content/de" to that without reading any file,
and the translate_tree fixture moves from the made-up code "xx" to "de"
because unsupported codes now translate to English by design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
?iframe=<url> loaded any http(s) page into the dashboard frame, and
"Open in new tab" handed the same query value to window.open unchecked,
so a crafted link could show an arbitrary site inside the trusted page.
CodeQL flagged both as client-side URL redirection and XSS. The scheme
check sat before the fade callback that sets the iframe src, so it did
not guard that sink, and isSafeUrl itself assigned the untrusted value
to an anchor's href to parse it.
isAllowedIframeUrl now requires a safe scheme and an origin that is the
page's own or one of the configured .iframe-link targets; both query
string entry points check it before openIframe or window.open run.
Clicks on configured links and popup entries keep calling openIframe
directly, which still rejects unsafe schemes. isSafeUrl parses with
new URL instead of a detached anchor.
The Cypress case that expected https://example.com/ to open from the
query string encoded the redirection, so it now asserts that a
configured target opens and that a foreign origin neither loads in the
frame nor reaches window.open. make test passes with 109 Cypress tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image ran npm install without --omit=dev, so every published tag
carried Cypress with its downloaded binary, ESLint and the whole dev
tree next to nodejs and npm. The npm install layer alone was 229 MB of
the 349 MB compressed 2.0.0 image, and all seven npm audit findings
(extract-zip, form-data, tmp, qs, uuid via @cypress/request) came from
it, although the app only serves five vendored asset directories.
A node:22-slim stage now runs npm install --omit=dev, whose postinstall
writes static/vendor, and the final runtime stage copies just that
directory onto the Python base. A dev stage keeps nodejs and npm for
docker-compose, which bind-mounts app/ and runs npm install on start;
compose now builds that target. .dockerignore keeps a local
app/node_modules and app/static/vendor out of COPY app/.
Measured on make build: the runtime image is 142 MB uncompressed, has
no node, npm, node_modules or Cypress cache, and serves /, /de/ and
every vendored asset with 200. The dev stage has node 20.19.2 and npm
9.2.0. make test passes, hadolint included, with 107 Cypress tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make test installed the project into whatever PYTHON pointed at, which is
the system python3 by default. On Manjaro, Debian and Ubuntu that Python
is externally managed, so pip refuses (PEP 668) and not a single suite
runs, although AGENTS.md requires make test before every commit. It also
stopped at the first failing suite, so one broken linter hid the result
of every test behind it.
make test now creates .venv on first use and runs ci with --keep-going
and that interpreter, so every suite runs and make lists each failed
target. The path is fixed to the checkout: an exported VENV, which other
tooling sets, would otherwise redirect it. It is absolute because
run-e2e.sh changes into app/ before starting Flask. All other targets
keep PYTHON ?= python3.
Verified with a full make test: actionlint, ruff, yamllint, eslint,
hadolint, shellcheck, the lint, security, unit and integration suites,
bandit, pip-audit and 107 Cypress tests all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/lint/test_tooling_configuration.py imports yaml, but the lint test
job only set up Python and ran unittest, so every push since the test
arrived failed with "ModuleNotFoundError: No module named 'yaml'". The
job gates the end-to-end tests and the image publish, which is why no
image exists for 2.1.0 or 2.1.1.
The job now installs the project exactly like the unit, integration and
security jobs, and make test-lint depends on install like its siblings.
Reproduced in a clean virtualenv: the lint suite fails on the missing
module without the install and passes all 18 tests with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>