Safari, the iOS home screen, Android install and push notifications
still showed the old film-reel artwork beside the new theme colour. The
PNGs are now rendered from the SVG mark by `npm run icons` (Inkscape,
no image library in node_modules): the bare mark for the 32px favicon,
the mark on the stage colour for app icons, and a maskable variant with
the extra margin Android's adaptive crop needs.
Gold is the primary in both themes, Bricolage Grotesque carries the
wordmark, titles and the score, and Movie A / Movie B wear the blue and
magenta of three-strip Technicolor on their posters instead of colouring
whole rows. The loop closes on screen - connectors light, a gold line
draws back to Movie A, the row glows, then the result arrives - in place
of confetti, on every play mode, scaled to the chain's length. Every
page shares one header and hairline lists; copy is sentence case with
one verb per flow, and the Home hero shows a real finished loop.
Also clamps link counts at zero: an opponent who had not moved yet
showed "-1 links".
Enabling push in production failed with "Validation failed": the
browser's PushSubscription.toJSON() carries expirationTime, and it was
sent verbatim to a schema that is strict about unknown fields. The
client now sends exactly endpoint + keys. The test doubles had been
shaped like the schema rather than like a browser, which is why 1,112
tests said nothing.
The CSP hash for index.html's inline theme script was hardcoded in the
container's start-up script behind a comment asking for it to be
regenerated by hand, and claiming a test asserted it; none did. The
bytes are what the browser hashes, so an edit or a checkout with
different line endings would have silently blocked the page's own
script. The hash is now emitted by `npm run build` from the index.html
that is actually shipped, copied into the image, and read at start; the
container refuses to start without it. Verified against the built
image: header hash equals served-script hash.
For the record: a CSP console message in production is what led here,
and the served page turned out to be correct - that message was a
browser extension's injected script, which the policy is right to
refuse.
The repo already stored LF throughout (audited: 0 of the tracked text
blobs contain CR), but a Windows checkout with core.autocrlf=true
converted on the way out - which is how a built image once shipped a
CRLF shebang (N9). `* text=auto eol=lf` makes the checkout rule part of
the repo rather than each developer's config. Renormalising changed no
content.
Played end to end against a clean-install dev stack: Quick Play, a Custom
Game loop closed and scored server-side, a hint, an undo, every anonymous
route. Four things the 1,112 unit tests had not caught.
Picking an actor flips the search to movie mode while the 300ms debounce
still holds the actor's name, so a movie search for "Daniel Craig" ran
and opened a listbox under an empty input; an empty input now means
nothing to search. The hint button said -150 while the server charges
200. A fresh install's empty daily-challenge day (a 404 until the midnight
job) read as "Failed to load". And every abandoned request logged
"[API Error] canceled", two per page change.
The bar codified in WP-14 is cleared as of 2.2.1, so the job that was
deliberately left as a plain run - "gating CI on a bar the codebase is
known not to clear would be a permanently red job nobody reads" - now
enforces it.
WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97%
branches across 93 files, with every source file above the codified 90%
thresholds on its own - no per-file exceptions.
Written risk-first: real Zustand stores seeded via setState, the axios
client mocked with an unrouted-request tripwire, the socket factory
mocked with a controllable fake, so no test can reach a backend and the
stores are covered by the pages that use them as well as directly.
The tests found six defects, each pinned first with it.fails and then
fixed: a failed join to an open Game Night room was still silent (the
error only rendered inside the password prompt); the remove-friend
button had no accessible name; signing out from Profile landed on
/login because the page's auth guard re-ran; the sign-in page neither
redirected a signed-in user nor read the ?expired=1 the API client has
sent since WP-10; and Quick Play / Endless threw out of their click
handlers on a TMDB failure while every start page navigated to /game
whether or not the game had started. startGame now reports its outcome
and the pages branch on it.
One toolchain finding worth keeping: vi.useFakeTimers() combined with
@testing-library/user-event deadlocks under this vitest/jsdom
combination; tests that need both use real timers and waitFor.
WP-17. Minor: push notifications become usable and nothing a player could
reach was removed. The admin console's jobs need backend 3.0.0.
Push (M35): the whole browser half - the service worker's handlers, the
subscribe/unsubscribe calls, the server's VAPID key - had shipped with
nothing calling it, so no browser ever held a subscription. The toggle
lives in Profile because the permission prompt has to come from a click.
The old isPushSubscribed awaited serviceWorker.ready, which never settles
with no worker registered; it uses getRegistration() now.
Admin: each job pages and the button becomes Continue with the server's
cursor; the recalculate card also stopped printing "undefined leaderboard
entries" for a field the server had not sent for several releases. The
three copy-pasted job cards are one component.
Consolidation: the four game screens share use-game-screen and one search
panel; the review page's static chain is ChainDisplay in a static mode
(21 rendered states verified byte-identical); formatTime and error
extraction exist once; Endless and quick play share a franchise-aware
picker instead of two divergent sort(() => Math.random() - 0.5) copies.
The dead api/index barrel and the fourteen functions only it referenced
are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected
and every preset title is checked against TMDB.
Same guard as the backend: the back-push step used `git tag ... || true`,
so an existing v<version> tag made it a silent no-op and left the version
pointing at an older commit. Checked before anything is built.
eslint-plugin-jsx-a11y joins the lint step and found 19 violations on
arrival - 15 orphaned labels, a clickable div, a combobox missing its
required attributes, an autoFocus. All fixed, none suppressed, and the
rule set stays enforcing so the next one fails CI.
The two hand-rolled overlays were the worst of it. The notification bell
was a div with div-onClick rows: nothing focusable, nothing announced, no
way to close it from the keyboard. It is a real popover now - the
trigger reports its state and the unread count, the panel is a labelled
dialog, every row is a button with its actions beside it rather than
nested inside it, Escape closes and returns focus. The Challenge Friend
overlay had no role, no focus trap and no Escape, so Tab went straight
through it into the page behind; it is the UI kit's Dialog now, which
supplies all of that.
Every form control has a label, including the three change-password
fields that had only placeholders and a game-night join-password field
that had nothing at all. Every icon-only button says what it does,
including three versus buttons whose content collapsed to a bare spinner
while loading. The search combobox follows the ARIA 1.2 pattern, with
the role on the input; verified live that aria-expanded flips and
aria-controls resolves to the listbox.
State that changed silently is announced: link validation, both lobby
countdowns (assertively - a 3-2-1 that launches the game is not
something to miss), the game-night player list. The game header's
"Passed Movie B" read identically in both states and only the icon
differed, so a screen reader heard the wrong thing until B was reached.
A skip-to-content link on every page, and a global reduced-motion rule
that collapses every animation when the OS asks; the confetti overlay
does not render at all under it.
Addresses audit section 2.8.
App.tsx imported all 24 pages statically, so a first-time visitor
downloaded the admin panel, three multiplayer modes and the preset-pair
table before the landing page could render - one 682 KB chunk. Every
page except Home is now React.lazy behind a Suspense boundary: 56
chunks, pages at 2-11 KB. Verified in a browser against the production
build - the landing page fetches only the app and vendor chunks, a
navigation fetches that route's chunks and renders, and a deep link into
an authenticated route lazy-loads it, redirects to sign-in, and
lazy-loads that too.
Vendor code is split into vendor-react, vendor-router, vendor-ui and
vendor-net via manualChunks. The main chunk was 95% dependencies, so the
first load is not much smaller - the dependency set is the load - but
the chunk that changes on every deploy is now 50 KB (14 KB gzipped)
rather than 682 KB. A returning visitor after a deploy re-downloads the
app, not React.
The favicon was 888 KB: PNG data under a .jpg name, 768x768, with a
fully opaque alpha channel, fetched on every page load. It is a 2.6 KB
32px PNG now, with a 180px apple-touch-icon, and manifest.json points at
real 192px and 512px PNGs with the correct type instead of declaring the
same mislabelled file at both sizes. The service worker's notification
icon pointed at a /favicon.ico that never existed.
The useGameStore selector item from M26 was already done in WP-10.
Addresses M26.
There was no test runner. This adds vitest with @testing-library/react and
jsdom, a `test` job in CI, and the first 51 tests.
They start where the audit's most user-visible defect lived. C1: every
multiplayer mode started a game through startGameFromVersus, which never
created a backend session, so `score` was only ever set inside
`if (sessionId)` and the completion modal spun on "Calculating score..."
forever, with no way out and an opponent waiting on the other side. The
store tests pin the repaired path end to end - a multiplayer start
creates a session, closing the loop lands on a score or a visible error,
never the silent null - along with the generation guard that keeps a slow
session from game A out of game B, and the serialised undo. The
chain-validation hook is covered in both its server-validated and
anonymous client-side forms.
Coverage thresholds of 90 on every metric are codified in vite.config.ts.
Two files are tested, at 100%/97.9% and 100%/91.4% branches, so
`npm run test:cov` fails honestly on the whole-tree number while CI runs
the plain `npm test`; the gap is real and is the next frontend work.
The tests intercept the axios client rather than the API modules. An
early version mocked `@/api/games` directly and one test sent a real
request to the development backend: a second concurrent dynamic
`import()` of a mocked aliased module resolved to the real file under
vitest. The client is imported statically, which vitest mocks reliably,
and the routing carries a tripwire that fails loudly on any unrouted
request so a bypass can never reach the network again.
Addresses L1.
BREAKING CHANGE: the production container listens on 8080 instead of 80.
nginx now runs as an unprivileged user and cannot bind a privileged port
without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to
this container must be repointed - in the homelab Traefik config,
traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080.
nginx's add_header is not additive across levels: a block declaring any
add_header of its own discards every header inherited from its parent.
Both /assets/ and /config.js declared one, so X-Frame-Options,
X-Content-Type-Options and Referrer-Policy were silently absent from
every JavaScript and CSS response. The headers now come from a snippet
included in each location - verified 6/6 on /, on a hashed asset bundle,
and on /config.js.
Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at
container start from API_URL, because the API is deliberately not
same-origin in production; asserting same-origin would have broken the
deployment outright. The inline theme script is allowed by SHA-256 hash
rather than unsafe-inline, which would have bought almost nothing -
editing that script means regenerating the hash.
config.js is also no longer rendered with envsubst, which does no
escaping: an API_URL containing a double quote closed the JS string
literal and the remainder executed on every page load.
Addresses M31, M34.
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of
H11. Steps 5 and 7 of the package were already done in WP-1.
An expired token left the app half signed in: the interceptor only wrote to
the console, so the nav still showed the user's name while every request
failed. A 401 now clears the token and navigates to sign-in - a full
navigation rather than a router push, so in-memory state and open sockets go
with the page, and the API client does not have to reach into the stores and
join an import cycle with them. A 401 from /auth/login is excluded: there,
it is an answer.
Signing out disconnected the notifications socket and nothing else. Chat,
versus and game night stayed open holding a token that had just been removed,
and the friends and game stores kept their contents, so signing in as someone
else on the same tab showed the previous person's friend list. Teardown now
lives in one place, because the failure mode was precisely that adding a
socket did not mean remembering to close it.
All four connect() guards tested `.connected` rather than existence, so a
socket that was mid-reconnect was replaced without being closed and the
orphan went on handling every event a second time. disconnect() likewise
cleared only the socket, leaving matchId, result and score behind - the next
match opened showing the previous match's completion modal.
Effects depended on the whole `user` object, which loadUser() rebuilds on
every call, so they re-ran when nothing had changed. In AsyncVersusGame that
effect's cleanup calls resetGame(), so reloading a profile mid-match reset
the board. They depend on the id now. The same effect also reset the store
while its fetch was in flight, so init() then wrote the match back into what
had just been cleared; it aborts before resetting.
Page reads take an AbortSignal and their effects abort on unmount. Several
already had a `cancelled` flag, which stops the state write but lets the
request run to completion; the flag stays to guard the `finally` blocks.
Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight
against a server that removes one link per call, and a failure was logged
while the local chain had already been shortened. Undos are serialised and
the server's chain - which it was already returning and the client was
discarding - is now authoritative.
The runtime API setting was substituted into a JS string literal by envsubst,
which does no escaping, so a value containing a quote ended the literal and
the remainder executed on every page load. It is escaped at render time
(verified under busybox with the production values and with an injection
payload, which comes out inert), and a malformed value is rejected on read.
Note this deliberately does not assert same origin, as the plan suggested:
the frontend and API are served from different subdomains in production, so
that assertion would break the deployment.
The Endless difficulty selector is removed rather than wired. Difficulty is
derived from cast analysis on the server and Endless builds its pairs in the
browser, so there was nothing to wire it to - it silently did nothing while
telling the player their choice mattered.
Verified in the running app: an invalid token clears and redirects to
/login?expired=1 without looping, while a wrong password shows "Invalid email
or password" on the form and stays put; sign-out clears the token, returns to
sign-in and leaves no trace of the previous user; Endless renders without the
control and keeps its real filters. No console errors beyond the 401s those
checks deliberately provoked.
Fallout from the backend's 2.0.0, found by checking the frontend against a
running backend rather than by compiling it.
The async leaderboard declared `movieATitle` and `movieBTitle` as required
and rendered them directly. The backend now withholds the pair from anyone
who has not played that match - the pair is the puzzle - so for a
non-participant both are absent and the heading rendered as a bare "↔"
between two blanks.
TypeScript could not catch this: these API types are hand-written rather than
generated from the backend, so a field the server stops sending still
compiles. The types now say the titles are optional, which is the truth, and
the page explains that the movies are hidden until you play.
Every path the UI offers to this page belongs to a participant, so this was
only reachable by opening the URL directly - but the type was wrong either
way, and a wrong type is what hides the next one.
Same root cause as the backend fix: no .gitattributes plus core.autocrlf
meant docker/40-render-config.sh was checked out with CRLF on Windows and
copied into the image that way.
Less severe here than in the backend, because nginx's docker-entrypoint.sh
sources a non-executable file in /docker-entrypoint.d rather than exec'ing
it, so the CRLF shebang is not fatal. It is still wrong, and it puts the
config-rendering step one permission change away from failing the same
opaque way. Pinning it keeps both images consistent.
`npm audit fix` cleared 23 vulnerabilities (14 high) entirely within
existing semver ranges - lockfile only, no package.json version changes.
`npm update` then pulled in-range updates including react 19.2 -> 19.3 and
@base-ui/react 1.2 -> 1.8.
shadcn, tailwindcss, @tailwindcss/vite and tw-animate-css move to
devDependencies. shadcn in particular is a scaffolding CLI with zero
runtime imports that was dragging msw, @modelcontextprotocol/sdk,
@babel/core and ts-morph into the production dependency tree - the tree
`npm ci --omit=dev` installs and the Trivy filesystem scan reads. The
production image is nginx serving dist/, so none of them ever shipped; they
were simply misclassified. The build stage installs all dependencies, so
the production build is unaffected.
The react-hooks plugin update (7.0.1 -> 7.1.1) caught five more
set-state-in-effect cases, all fixed rather than suppressed:
- SearchAutocomplete derives "query too short" and its loading/error state
from which search has settled, instead of clearing state on every
keystroke below the threshold; the mode-change reset moves to a
render-phase adjustment, which also keeps focus that remounting via a
`key` would have dropped mid-chain.
- Leaderboard uses the same derived request-key pattern as the other list
pages, and gains the cancellation it was missing.
- Hint visibility is derived from the chain length the hints were fetched
for, rather than cleared by an effect.
- The async submit guard becomes a ref: as state it was both the guard and
a dependency, so setting it re-ran its own effect.
Verified in the browser: search, the mode reset, the Base UI dialog and a
full scored game all behave correctly, and the production image builds.
Minor: the completion, hint and match-submission contracts change shape, and
a new hint endpoint is added. Old clients are rejected loudly rather than
silently mis-scored.
The server no longer accepts a score, a chain or a hint count from the
client, so the callers change to match.
- completeGameSession posts no payload; the server scores the chain it
validated and the hints it recorded.
- Hints go through POST /games/:id/hint, which is what makes the hint
penalty enforceable - it was previously counted in the browser and
self-reported at completion, so it was effectively optional. The server
also picks the actors, replacing a client-side shuffle that used
sort(() => Math.random() - 0.5), which is neither uniform nor a valid
comparator.
- Versus, game-night and async submissions send the session id instead of
a ScoreBreakdown. Sending a score object is what allowed any player to
win any match by posting a large enough number.
- Stale hints are cleared when the chain changes; the previous movie's
cast used to stay on screen after picking an actor another way.
Verified end to end: a custom game with one hint and one duplicate link
scores 2519, with the -200 hint penalty and -150 duplicate penalty both
applied from server-side state, and the obscurity bonus computed from real
TMDB popularity rather than anything the client claimed.
Supersedes the 1.0.x release commits on this branch. Those were held back
on the grounds that a v1.1.0 git tag already existed - which was wrong: the
three local tags (v1.0.0, v1.1.0, v1.1.1) were never pushed, and the remote
has no tags at all, so nothing was actually taken.
Minor is the correct bump. Requests that previously failed now succeed -
duplicate chain links are accepted, and validation responses carry a
per-field errors[] array - which is backward-compatible new behaviour
rather than a fix alone.
Both repos are set to 1.1.0 so the pair stays aligned. Still no tag; the
stale local-only tags are worth deleting to avoid confusing `git describe`.
Adds the changelog this repo was missing. Git tags currently run ahead of
package.json (v1.1.1 exists while package.json was 1.0.1), so the tag line
and the package version still need reconciling separately - no tag is
created here.
Two separate reasons the containerised dev workflow did not work:
- CMD ran `npm run dev -- --host`. With no TTY under `docker compose up
-d`, npm printed its banner and exited 0 immediately, so the dev server
never started. Invoking vite directly keeps it running.
- Vite's watcher receives no file-change events through a Docker Desktop
bind mount, so it served stale modules indefinitely: edits appeared to
have no effect and HMR never fired. Polling is opt-in via
VITE_USE_POLLING, set by the compose file, so native dev is unaffected.
react-hooks/set-state-in-effect and react-hooks/purity had been demoted
to warnings "so CI passes", and CI ran without --max-warnings 0, so all
22 violations were invisible there. Both rules are back at error.
- Confetti is rolled once per mount instead of during render, where it
re-rolled on every re-render and made pieces visibly jump.
- Loading flags are derived from a "last settled request" key rather than
set synchronously inside an effect. That also adds the cancellation
these fetches were missing, so a resolved request can no longer write
into a unmounted or superseded view.
- Effect dependency arrays corrected; the two eslint-disable-line
comments in Profile.tsx are gone, and the split of its two effects
removes the guard that silently never re-ran.
- GameCompletionModal derives visibility from status instead of mirroring
it into state, with a ref so the sound and localStorage write fire once.
- ValidationFeedback adjusts state during render, the documented pattern,
instead of paying an extra committed render per link.
- The three react-refresh suppressions in ui/ are resolved properly by
extracting the variants into their own modules, so the component files
export only components. badgeVariants had no consumers and is no longer
exported.
- Drops a stale eslint-disable directive in sw.js.
The lint script now fails on warnings so this cannot quietly regress.
Mirrors the server-side change: reusing an actor or movie was rejected,
but project-plan.md section 4 calls for a points penalty instead, so that
a player is never stranded. Reuse is now accepted with a non-blocking
warning that says it costs points, and the penalty shows up in the score
breakdown at completion.
Also selects store actions individually rather than destructuring the
whole store, which was re-rendering every consumer on each keystroke.
Versus, Game Night and Async Versus could never complete a match.
startGameFromVersus was a copy of startGame minus the session-creation
block, so sessionId stayed null. score is only ever assigned inside the
`if (sessionId)` branch of the completion handler, and every multiplayer
submission is gated on score being non-null - so the submit never fired,
the opponent was never told, and the match never resolved. The completion
modals had no dismiss path, making it an unrecoverable dead end.
- startGameFromVersus now creates the session and reports the outcome as
'started' | 'superseded' | 'failed'. The middle case matters: React
StrictMode remounts the effect and its cleanup resets the store, which
must not be reported to the player as an error.
- Completion moves into finalizeGame, which awaits an in-flight session
rather than racing it (a fast player could close the loop first) and
always resolves to a score or a visible error, never a silent null.
- A generation counter retires work belonging to abandoned games, so a
slow session-creation response can no longer attach itself to the
next game.
- Achievements are no longer stored on `score`. They were posted back to
the API, where forbidNonWhitelisted rejected the submission with a 400.
- A failed async submit is no longer retried on every render; it
previously fired ~20 identical failing requests.
- The versus and game-night completion modals gained a way out so a
stalled opponent is not a trap.
Verified end to end: an async match played to completion submits, and
Postgres records the score against both the match and the session.
No frontend code changes — version bump only, to stay in lockstep
with backend 1.0.1 for easier deploy-time version matching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the backend fix. HARBOR_HOST is a Gitea Actions variable, not
a secret; secrets.HARBOR_HOST was empty the whole time. Use
vars.HARBOR_HOST and drop the now-pointless protocol-strip defense.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the backend fix: the pre-check cleaned HARBOR_HOST locally
but subsequent steps (docker login/push, cosign) saw the raw secret
with its https:// prefix, causing docker login to fall back to
docker.io. Do the cleanup once in Compute and write it to GITHUB_ENV
so every later step sees the same normalized value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the backend fix — the curl exit code 6 (DNS failure) was
propagating as a bare step failure with no useful message. Echo the
URL, strip protocol prefix from HARBOR_HOST defensively, and turn
each curl outcome into a clear error or warning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the backend push-job rewrite:
- Replace docker/login-action@v3 (fast 1s failure on the Gitea
runner) with plain `docker login --password-stdin`.
- Read VERSION from package.json (jq), SHA_SHORT from git, publish
:VERSION, :SHA_SHORT, :latest. Drop floating :1.2, :1 tags.
- Pre-check Harbor for an existing :VERSION artifact and fail early
with a "bump package.json" message instead of overwriting silently.
- Sign each pushed image with cosign. Signature is on the digest
(resolved via :SHA_SHORT), so it covers all tags pointing at it.
- After a successful image push, create and push a `v${VERSION}` git
tag back to origin using the auto-injected token. Tag-push failure
is a warning, not an error.
- Drop `tags: ["v*"]` from the workflow trigger — CI now creates the
tag itself, so re-triggering on tag push would loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lint had it explicit; typecheck relied on setup-node's lockfile
auto-discovery, which can fail with a fast (1s) exit on the Gitea
runner. Mirror the backend fix and set the path explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trivy's image scan flagged nghttp2-libs CVE-2026-27135 (HTTP/2 DoS),
inherited from nginx:alpine. The fix exists in Alpine's repos
(1.68.1) but the base image's pinned tag is still on 1.68.0-r0.
Run `apk upgrade --no-cache` in the production stage to pull current
security fixes from Alpine, then install gettext as before. This
keeps us current with Alpine security patches independent of how
fast the upstream nginx:alpine tag rebuilds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the backend fix. The Gitea runner times out reaching the
GHA artifact cache backend during cache-to export, so drop type=gha
cache-from/cache-to entirely and fold trivy image scan into the
build job to avoid a wasted rebuild.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous pin to v0.58.1 returned a 404 — that tag doesn't exist in
the trivy releases. Latest is v0.70.0; pinning to that. Verified the
asset naming convention (trivy_X.Y.Z_Linux-64bit.tar.gz) is unchanged.
Same pattern as the gitleaks fix: aquasecurity/trivy-action@master does
a nested actions/checkout to fetch its install script, which fails on
the Gitea runner. Switch fs-scan and image-scan to download the trivy
binary release directly and invoke it. Pinned to v0.58.1.
gitleaks/gitleaks-action@v2 has a license-key check that fails on some
Gitea runners. Switch to downloading and running the gitleaks binary
directly — same scan, no action wrapper, no license dependency.