CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# Contributing to OpenDiving Web
Thanks for wanting to help. Bug reports, a typo fix, a rough edge on a form, a whole new page — all
welcome.
For anything bigger than a small fix, **open an issue first** so we can agree on the shape before
you spend an evening on it. That goes double for changes that need new endpoints: those start in
[opendiving-api](https://github.com/opendiving/opendiving-api).
Found a security problem? An issue is the wrong place for it — [SECURITY.md](SECURITY.md) says where
it goes.
Participation is covered by our [Code of Conduct](CODE_OF_CONDUCT.md).
## Getting set up
The web app is a frontend for the API, so start that first (one `docker compose up` in
[opendiving-api](https://github.com/opendiving/opendiving-api)), then:
```bash
npm install
cp .env.example .env # points at http://localhost:8000/api/v1 by default
npm run dev
```
Open <http://localhost:3000> and sign in with your email. With no email provider configured on the
API side, the magic link is printed to the API logs — that's the intended local flow, not a bug.
Node 24 is what this builds and tests on — `.nvmrc` and `package.json`'s `engines` say so, and CI
and the Dockerfile follow. Use `npm ci` rather than `npm install` when you just want a
lockfile-exact install.
## Before you open a PR
CI runs lint, type-check, tests and a build, plus the end-to-end suite described under
[The instant-navigation tests](#the-instant-navigation-tests) — which is a separate command, because
it needs a production build of its own. One command runs the rest locally:
```bash
npx playwright install chromium # once per machine, see below
npm run ci
```
**The first line is not optional, and skipping it fails rather than skips.** Most tests run under
jsdom; the ones that ask it for something it has not got run in Chromium instead, through Vitest's
browser mode. `npm install` does not fetch a browser — `playwright` publishes no install script, so
that stays true and is deliberate — and the browser has to be asked for once. Without it that
project fails to start, which is the right way round: a test that silently skipped would be worse
than one that stops you.
Individually: `npm run lint` (`lint:fix` to autofix), `npm run type-check`, `npm test`
(`test:watch`, `test:coverage`), `npm run build`. Formatting is Prettier — run `npm run format`
before pushing, or `npm run format:check` to see what it would touch. The code-quality workflow runs
`format:check` too and fails on it, so an unformatted file is a red check, not a note.
`npm run format` covers the markdown at the repo root as well as the code, so `DECISIONS.md` — the
one you will be appending to — is formatted like everything else. Write the section however it comes
out and let Prettier wrap it to 100 columns; there is no need to match the surrounding lines by
hand.
A separate code-quality workflow also runs `tsc --strict` and an axe accessibility scan of the
running app. The scan is a report; the strict type check and the Prettier check above are not — keep
both clean.
## How the code is laid out
```
src/app/ App Router pages, one directory per route
src/components/ feature components (dives/, gear/, ...) + ui/ for Base UI wrappers
src/lib/api/ axios clients, one module per API area
src/lib/validations/ Zod schemas backing the forms
src/hooks/ shared hooks (pagination, auth guards, drag-sort, ...)
src/contexts/ AuthContext
src/map-renderer/ the map renderer, a Node program shipped in the same image
```
Components in `src/components/ui/` wrap the official shadcn Base UI components in
`src/components/ui/primitives/`. We own the generated source. Put app styles and behavior in the
wrappers where possible; review CLI overwrites and reapply the changes listed in `DECISIONS.md`
under "UI controls use Base UI". `components.json` directs shadcn CLI additions to the generated
directory. Add shared controls to `ui/` rather than hand-rolling one in a feature directory. Charts
(the dive profile, the consumption trend) are hand-written SVG on purpose; there is no charting
dependency and we'd like to keep it that way.
Tests are colocated: `foo.ts` gets `foo.test.ts` next to it, run by Vitest. Most of them use jsdom;
a file named `foo.browser.test.tsx` belongs to the second project instead and runs in real Chromium
(see the note above about installing it). Reach for that only when jsdom genuinely cannot answer the
question, because a real browser is slower and the isolation is weaker. What qualifies is a property
of the question rather than a subject area: jsdom has no WebGL2 context, and it performs no layout
at all, so a question about a real renderer or about measured geometry belongs in that lane and
everything else does not. Tests of code that only runs on the server — the map renderer under
`src/map-renderer/`, the middleware, route handlers and the modules behind them — run in a third
project, in plain Node, which is where that code runs. That project names its files in
`vitest.config.mts`'s `NODE_TESTS`, so a new server-only test is added there.
**A geometry assertion there is vacuous without `import "@/app/globals.css"` in the test file.** The
browser project renders no `app/layout.tsx`, so none of this app's Tailwind is loaded: without that
import `h-12` measures 0px and `flex items-center` computes to `display: block`, and the test passes
against exactly the markup it was written to reject. Read "jsdom answers no layout question, and the
browser lane only answers one with the stylesheet loaded" in [DECISIONS.md](DECISIONS.md) before
writing a test in either project — it has the worked examples, and the negative control to run
against your own.
Coverage is measured over `src/lib/**`, `src/hooks/**`, `src/contexts/**`, `src/components/**`,
`src/app/**`, `src/proxy.ts` and `src/map-renderer/**`, with floors per directory in
`vitest.config.mts`. CI does not collect it; `npm run test:coverage` prints the report and checks
the floors. New helpers in `src/lib/` should come with tests; bug fixes should come with a test that
fails without the fix.
## Running the map renderer
The map tiles the API stores, which the dive and trip cards and the heads of the dive, trip and dive
site pages are composed from, are drawn by a second program in this repository, `src/map-renderer/`,
which the API calls. It ships in the same image and is started with `node map-renderer/index.mjs`
instead of the web server's command. To run it beside the dev server:
```bash
npm run map-renderer # builds it, then listens on :3001
```
On macOS that is all: MapLibre Native, which it draws with, ships macOS binaries and needs no X
server. It reads `.env` as the dev server does — the same `MAP_*` variables and `SITE_URL` — plus
`PORT`, which defaults to 3001 so it does not collide with `next dev`. To have a local API draw
through it, set `MAP_RENDERER_URL=host.docker.internal:3001` in the API's `src/.env` and restart its
compose stack: the API runs in Docker and reaches your machine by that name.
On Linux it needs Ubuntu 24.04, the only Linux MapLibre Native ships binaries for, with `xvfb`
installed: the renderer starts its own X display. Anywhere else, build the image and run that
command in it.
## Two things that will bite you
**Always run a full `npm run build` after touching a Zod schema tied to a form.** Never use
`z.preprocess()` or `.transform()` on fields feeding a `z.input<>`-derived form type — it collapses
the input type and breaks `useForm<T>()`'s binding to `onSubmit` with confusing errors that show up
_only_ in a full build, not in the editor. Keep the schema's input and output types identical and do
the conversion in a plain helper right before the API call (see `normalizeMixtures` in
`lib/validations/dive.ts`).
**Grep [DECISIONS.md](DECISIONS.md) for what you are about to touch.** It records the non-obvious
choices and the traps already hit — API client signatures, React Hook Form quirks, auth and token
handling — and is far too long to read whole. When you make a decision the code cannot explain by
itself, append a short section as part of your PR: under 150 words, present tense, what was chosen
and why. Most reasons belong in a one-line comment at the site instead.
## README screenshots
The images in `docs/screenshots/` are generated, not hand-cropped. With the API and the dev server
both up:
```bash
npm run screenshots -- you@example.com
```
It signs itself in with a magic link read out of the API container's log, so it only works against a
local stack. Chromium comes from your existing Chrome install (override with `CHROME_PATH`);
`playwright-core` only drives it, so `npm install` never downloads a browser.
Copies of these images are on the front page of
[opendiving/opendiving](https://github.com/opendiving/opendiving), which has no way to retake them —
the app is here. So the script writes that repository's copies too when a clone of it sits beside
this one (`../opendiving`, or `PRODUCT_DIR`), from the same shutter press. With no such clone it
says so and writes only this repository's, which is what a contributor with one checkout gets.
Committing the regenerated files over there is a separate, manual step — and so is putting a
newly-named shot on that README, which names the images it renders one at a time. This script writes
files; it does not lay out anyone's page, least of all another repository's. Until that edit is made
there, the two front pages show different sets, and only the files they share are identical.
The subjects — which dive, which gear item, which dive site — are picked out of the account's own
log and so move between runs. `DIVE_UUID=<uuid>` pins the dive when the pick is a dull one, and
throws rather than falling back if that dive cannot be read or has no recording to chart. The other
two have no equivalent.
Every shot uses one width, and the year the two Home charts are parked on is a constant beside it at
the top of `scripts/screenshots.mjs`. No dimension is a round number — 1024 is `lg`, where the
detail pages stop stacking their sidebar, and the heights are measured in the page moments before
the shutter — at the first line past a named card where no card is cut through, or at the foot of a
named card where the shot is about one column. The gear page is the exception: its height is a
literal, because what decides it is the README row rather than the page — it is the height at which
the gear and dive-site shots, stacked in one cell, come level with the dive page beside them — and
the script checks before the shutter that it still lands between two rows rather than through one.
So read the comments there before changing them, and see [DECISIONS.md](DECISIONS.md) for the rest
of the reasoning.
## Timing soft navigations
`scripts/measure-navigations.mjs` times what happens between a click and the destination being done
— when the RSC round trip ends, when the screen first changes, when the first grey is actually
_seen_, when the page's own data lands, and how much the source page prefetched before the click —
over a list of navigations through the app. Run it when a change is meant to alter how a navigation
feels, so that "feels faster" has a figure behind it.
The two change columns are separate because they diverge: a placeholder takes its space at once and
is held at `opacity: 0` for 150 ms, so a DOM mutation is not a moment anything was visible.
`REDUCED_MOTION=1` runs the same walk in a browser asking for reduced motion, where that delay does
not exist.
It needs a _production_ build, because prefetching is only enabled in production, and that build
wants a port of its own — `:3002`, beside the dev server on `:3000` and the map renderer on `:3001`:
```bash
NEXT_PUBLIC_API_URL= npm run build
API_INTERNAL_URL=http://localhost:8000 npx next start -p 3002
WEB_URL=http://localhost:3002 npm run measure-navigations -- you@example.com
```
Emptying `NEXT_PUBLIC_API_URL` is what puts that build on the same-origin route handler: the API's
CORS allowlist names `:3000` only, so a split-origin build fails every request made from `:3002`.
Next writes `next dev`'s output under `.next/dev/`, so none of this disturbs a dev server you have
running. It signs itself in the way `npm run screenshots` does, off a magic link in the API
container's log, so it needs the same local stack. The script's header carries the rest — which
navigations it walks, what each column means, and what to set when the defaults do not fit.
## The instant-navigation tests
`e2e/` holds three Playwright tests, one per navigation the app is judged on: dives → gear, dives →
a dive, and a step of the dive pager. Each asserts what is on screen at the click — the
destination's frame, and not its data — through `instant()` from `@next/playwright`, which scopes
the assertions to the UI the router could commit without waiting for anything.
```bash
npx playwright install chromium # the same browser the unit suite's Chromium project uses
npm run test:e2e
```
**They need no API, and no `docker compose`.** The fixtures are served out of the browser by
`page.route()`, so the app is signed in against a mocked `/api/v1` and every endpoint the three
navigations touch answers from `e2e/support/api.ts`. That file is also where a test holds a response
back, which is what keeps the destination's data out of the frame it is asserting on.
`playwright.config.ts` builds the app and starts it on its own port, so the command above is the
whole of it; there is nothing to start first and nothing left running afterwards. The build is a
production one — prefetching only happens in production — and it is a build of its own rather than
the one `npm run build` leaves behind, because `experimental.exposeTestingApiInProductionBuild` is a
compile-time flag and `next start` cannot add it afterwards. `next.config.js` reads it from
`NEXT_EXPOSE_TESTING_API`, which only that config sets.
A failing run leaves a trace under `test-results/`; open one with `npx playwright show-trace`.
## Design expectations
- **Dark mode and light mode both work.** Use the Tailwind theme tokens rather than hardcoded
colours, and check both before you push.
- **Mobile matters.** People log dives on a boat. Test narrow viewports.
- **Accessibility isn't optional.** Labelled inputs, keyboard-reachable controls, sensible focus
order. `react/no-danger` is an ESLint error: introducing `dangerouslySetInnerHTML` needs a
deliberate disable comment plus a sanitizer, not an unreviewed add.
## Pull requests
- **Your commits do not need to be signed.** PRs here are squash-merged, and GitHub creates and
signs that one commit with its own key, so what a self-hoster audits on `main` later is signed
whatever your branch carried. Sign if you already sign — nobody will ask you to set GPG up for a
pull request.
- Branch off `main`, keep the PR focused on one thing.
- **Your branch never needs to be current with `main`.** Once a PR is green and reviewed, a
maintainer adds it to the merge queue, which squashes it onto `main` as it then stands, runs the
required checks on that result and lands it. Rebase only to resolve a conflict.
- Title the PR as a conventional commit — `<type>[(scope)][!]: <description>`, e.g.
`feat: chart the dive profile on the dive detail page`. Types: `feat`, `fix`, `refactor`, `docs`,
`test`, `chore`, `perf`, `ci`, `build`, `revert`. A CI check enforces this, and re-runs when you
edit the title, so a rejected PR needs no new commit. PRs are squash-merged, so the title becomes
the commit subject on `main` — commits within your branch can say whatever is useful while
working. (Older history mixes prefixed and plain subjects; new PRs need the prefix.)
- Say in the description what you changed and why. Screenshots for anything visual — the README is
screenshot-heavy for a reason.
- **A change that spans both repos starts on the API side.** Open the
[opendiving-api](https://github.com/opendiving/opendiving-api) PR first, link it from this one and
this one from it, and expect the api PR to merge first — the endpoint has to exist before the page
that calls it. Nothing in CI runs the two together, so those two links are all that holds the pair
together for whoever reviews them.
## AI-assisted contributions
Welcome, and the repo is set up for them on purpose: `AGENTS.md` carries the conventions and
`DECISIONS.md` the traps already hit, both written for your tools as much as for you. Pointing a
coding agent at this codebase is a supported way to work, not something to leave out of the PR
description.
What it doesn't change is who owns the result. The human opening the PR owns what is in it: you ran
it — the app and `npm run ci` — you read the diff, you understood it, and you can answer review
questions about it without going back to the model. Bulk submissions nobody looked at first get
closed without ceremony. That is not a position on the tooling; it is the same bar the rest of this
file sets, and unreviewed output is just the quickest way to miss it.
## What to expect
One maintainer, working on this in spare time. That is the honest shape of it, and worth saying out
loud so that quiet is not read as a verdict: a first reply usually arrives within a few days, a typo
fix may merge the same evening, and something that needs thinking about can sit longer. If a week
goes by with nothing, ping the thread — that is useful, not rude. It almost always means the
notification got buried, not that anyone read your PR and decided against it.
## For maintainers
Commits pushed to this repository's own branches are signed, and a hook keeps them that way. Once
per clone:
```bash
git config core.hooksPath .githooks
```
`.githooks/pre-push` then refuses to push a commit carrying no signature at all. Linked worktrees
share the setting, so that single command covers them too.
That instruction lives here rather than in _Getting set up_ because it is not a contributor's
problem: the commits in a pull request do not need to be signed, and enabling this hook in a clone
that does not sign only walls you out of your own push.
## Cutting a release
Releases are cut deliberately, not minted per merge, and the version moves in lockstep with
[opendiving-api](https://github.com/opendiving/opendiving-api): one product version, so
`opendiving-api:0.4.0` and `opendiving-web:0.4.0` are always a matched pair. A pin that resolves in
one repo and not the other is a broken install, which is why neither repo picks its number alone.
The product's own release is a third tag, cut in
[opendiving/opendiving](https://github.com/opendiving/opendiving) after both images have published —
its workflow refuses to publish unless both exist at that version, which is the check no
per-repository workflow is in a position to make.
A merge to `main` is not nothing, though. It publishes `:edge` and `:sha-<12>` — no `:latest`, no
version alias — and then calls a Render deploy hook, which is how the project's own hosted instance
follows `main`. That channel is deliberately not a release: it never moves a tag anyone is pinned
to, so it is invisible to a self-hoster, and `edge` is not a tag you are asked to follow. A missing
deploy hook is a notice and a green build, not a failure, so a fork publishes the image and deploys
nothing.
**Nothing here is bumped or tagged by hand.** A release is one dispatch of
[Cut the release](https://github.com/opendiving/opendiving/actions/workflows/release-cut.yml) in the
product repo: it opens a `chore(release): v0.4.0` pull request here, waits for this repo's required
checks, squash-merges it and pushes `v0.4.0` at the commit that lands — and does the same in
`opendiving-api` before tagging itself last. Bumping a version or pushing a `v` tag yourself is how
the _next_ release gets stuck rather than this one: the coordinator refuses when the entries that
declare a version disagree, or when the newest tag is not what the manifest says, and either of
those is what a hand-cut release leaves behind.
**The bump is more than `package.json`.** `package-lock.json` carries the same number twice, in its
root object and in its `packages[""]` entry, and `npm ci` is perfectly happy when those disagree
with the manifest — so a bump that misses them drifts silently instead of turning a check red. The
coordinator writes all three entries in the one commit.
The tag push runs **Publish Image**, which compares the tag against `package.json` and fails before
anything reaches the registry if they disagree — so nothing is published from a bump and a tag that
say different things. Once the images are up it publishes this repository's release at that version,
with notes generated from the merged PRs and sorted by `.github/release.yml`. It is not a draft and
nobody finishes it by hand: the release an operator downloads from and reads before upgrading is the
product one, and this one is the record of what went into the image they pull.
**That release is live before the product's guard runs**, and that is the cost of nobody finishing
it. The product repo is tagged last so that its own workflow can refuse when either image is missing
or half-published — but by then this repository has already moved `:latest` and published its
release, so a refusal there leaves a published `v0.4.0` here for a product version that never
released. A tag that has published something is never repointed; a bad release gets a successor.
Everything else — the cadence, how to pick the number, what counts as breaking, the release notes,
and why the three tags go in that order — lives in the product repo's CONTRIBUTING under
[Cutting a release](https://github.com/opendiving/opendiving/blob/main/CONTRIBUTING.md#cutting-a-release).
It is deliberately in one place: a decision table kept in three repos drifts apart, and that one is
the copy self-hosters read.
## Staying on top of CVEs
Two things are wired up to raise the alarm, and they watch different objects — neither substitutes
for the other. What they raise it _for_ is one of two responses, so start with those.
**Rebuilding a published version.** A published version is never repointed — a bad release gets a
successor, not a rewrite — and a CVE in the base image is the one sanctioned exception. It works
because the `Dockerfile` floats on `ubuntu:24.04` and on `node:24-bookworm-slim`, which it copies
Node out of, rather than on digests, and because Publish Image builds without a cache, so the stage
that runs `apt-get upgrade` always runs: the build resolves a patched Node and the Ubuntu packages
published since the base was cut. Run **Publish Image** by hand with `ref` set to the `v` tag, and
tick **Also push :latest** if that version is still the newest. One run recomputes the version's
whole alias set, which is the point: a hand-picked subset would leave everyone following `latest` or
`0.4` on the vulnerable digest. That is this repository's workflow rebuilding this repository's
image — the api repo rebuilds its own the same way, and a base-image CVE will often want both.
**Cutting a new patch release** is the other response, and the two are not interchangeable. Which
one applies is the split below.
**The published image.** `.github/workflows/vulnerability-scan.yml` scans the newest release every
morning with Trivy — its `X.Y.Z`, `X.Y`, bare major and `latest`, which are one image under four
names, resolved to a digest so it is scanned once — and `edge` alongside it, which is the image the
project's own instance is running and the only one that exists before the first release is cut. It
reports HIGH and CRITICAL findings in both halves of each image: the Ubuntu packages, and the npm
packages `npm ci` installed — into `.next/standalone`, and into the map renderer's
`map-renderer/node_modules`. This is the job that closes the loop with the rebuild above, because
the case it catches is a release that was clean the day it shipped and grew a CVE three weeks later,
with no PR in flight and nobody looking.
**The alert is a code-scanning alert** on the repository's **Security** tab, and you are the one who
acts on it. The run also writes a summary to the workflow run itself, and that is the half worth
reading: it splits the findings by what actually fixes them, because they are not the same remedy —
and where a release is involved it names the exact `v` tag to dispatch at:
- **OS package** — the rebuild above. One dispatch recomputes every release alias the scan covers,
which is why the scan covers exactly those and no more.
- **npm package** — _not_ fixable by a rebuild at any tag. That version comes from the
`package-lock.json` committed at the tag, and the rebuild checks that tag out and runs `npm ci`
against it, so it reinstalls the identical version no matter how many bumps have since landed on
`main`. Merge the bump and **cut a new patch release** — the ordinary flow above, not the in-place
rebuild.
Rows on `edge` are neither of those, and no dispatch moves that tag — only a push to `main` does.
`edge` is rebuilt off `main`'s own tree, so an OS-package finding there clears itself on the next
merge and an npm one needs the bump merged and nothing else. If nothing is due to merge and it
cannot wait, an empty commit on `main` is the whole of the rebuild.
Every scan replaces the whole alert set, so a CVE that takes upstream a fortnight to patch is one
alert for a fortnight rather than fourteen notifications, and an alert closes itself on the first
scan that no longer finds it — you never close one by hand to say it is fixed. Only findings with a
published fix become alerts; the ones upstream has not fixed are a count in the run summary, because
no rebuild collects them and an alert you cannot act on is noise.
Until 2026-09-12 this alert was a GitHub issue labelled `image-cve` instead, because SARIF upload
was a paid feature on a private repository. Deleting that step left the last such issue with nothing
to update or close it, so it was closed by hand the same day; none has been filed under that label
since. Anything it reported that is still present comes back as an alert on the next scan.
**Proposed changes.** The same workflow runs a second, much cheaper job on every PR and every push
to `main` — Trivy over `package-lock.json`, no `npm ci` and no image built — which _fails the check_
on a HIGH or CRITICAL that has a fix available. That is about a change you are proposing rather than
about what is deployed, so it stays out of the alerts above. It ignores findings with no fix
published, because there is no move to make on those. It replaced `ci.yml`'s
`npm audit --audit-level=moderate`, and that swap was not a wash — see _What none of this watches_.
`code-quality.yml`'s **dependency-review** job is the third angle on the same PR, and the only one
that looks at what the change _introduces_ rather than at the tree it lands in. It fails on a
moderate-or-worse advisory in a dependency the PR adds or moves, and on a licence outside the
allow-list in that job — which is the point at which a new licence gets looked at rather than
absorbed. Both are a gate on the diff; neither says anything about what is already installed.
**Version bumps.** `.github/renovate.json5` is the other half: it watches `package.json` and
`package-lock.json`, the `Dockerfile`'s Node tag, `.nvmrc`, every pinned GitHub Action, and the
Trivy version `vulnerability-scan.yml` installs. Routine updates arrive in one batch on Monday
morning, and only for versions published at least three days earlier; a vulnerability-driven one
ignores both, and is titled `fix(deps):`, so it lands in the Fixes section of the release notes
rather than among the chores.
> **Renovate is running, and the
> [Dependency Dashboard](https://github.com/opendiving/opendiving-web/issues/190) issue is where it
> reports.** The Mend-hosted [Renovate GitHub App](https://github.com/apps/renovate) is installed on
> the `opendiving` org and reads `.github/renovate.json5` on its next run, so a change to that file
> takes effect without anyone enabling anything. It is granted per repository, not org-wide: a
> repository added to the org later has to be added to the app's repository list by hand, and
> nothing warns you it hasn't been. **A fork inherits none of this** — the config file travels with
> the code and the bot does not, so a fork installs the app on its own account or runs Renovate
> self-hosted on a schedule with a PAT, and until it does the pins here are frozen and nothing says
> so.
One thing Renovate will not do on its own is move Node. `.nvmrc`, `engines.node` in `package.json`
and the `Dockerfile`'s Node tag are all reachable to it; `node-version:` in `ci.yml` and
`code-quality.yml` is not, so an auto-opened PR would build the image on a new runtime while CI kept
testing the old one. That update is grouped and held behind a checkbox on the **Dependency
Dashboard** issue: it tells you a new Node is out and waits for a person, who edits the two
workflows by hand.
Two holds sit beside it, both for the map renderer's MapLibre Native, whose install script fetches a
prebuilt binary and never builds one. Its binaries cover Node 20, 22 and 24, so the Node group
offers nothing from 25 up — on any other ABI every `npm ci` here fails — and they are built for
Ubuntu 24.04 alone, so Renovate does not offer a newer Ubuntu at all. Both are lifted by hand in
`.github/renovate.json5` once the package ships binaries for the next one. A bump of the package
itself does arrive as a PR, and fails CI until someone moves its `allowScripts` pin in
`package.json` and records the new binary's hashes in `scripts/build-map-renderer.mjs`, whose
comment says how — that failure is the review step, not a fault.
To check what Renovate would actually do before trusting a change to that file:
```bash
npx --yes --package renovate@44.181.7 -- renovate-config-validator .github/renovate.json5
docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/renovatebot/renovate:44.181.7 --platform=local --dry-run=extract
```
Both name a version so the command means the same thing next month; move it when the validator
rejects an option the config has started to use.
The first catches a misspelled option; the second prints every dependency each manager found, which
is the only way to see that something read from outside a manifest — the Trivy `version:` input — is
still being watched. Run them in a checkout, not a worktree — the container needs the files, and
`--dry-run=extract` writes nothing and needs no token.
**What none of this watches**, stated so it is not mistaken for coverage:
- **Dev dependencies, and anything below HIGH.** That is what the `npm audit --audit-level=moderate`
job covered, and giving it up is deliberate rather than an oversight: it is the api repo's
threshold, and a moderate finding with no published fix in a package that never reaches the image
is a red X with no move attached — the kind of check that gets ignored rather than acted on. The
dev tree does execute in CI, and the answer to that is the one `ci.yml` and `code-quality.yml`
already chose — a `contents: read` token with `persist-credentials: false` — not a scanner.
`npm audit` is still one command away when you want the wider net.
- **Any release but the newest.** This is the scan agreeing with [SECURITY.md](SECURITY.md): nothing
is backported, so the supported version is the latest release. A dispatch only ever repoints the
aliases of the version it names, so scanning `0.2` would produce an alert with no supported move
attached, recurring forever. The four release aliases that _are_ scanned are every form in which
someone can be pinned to the supported release; `edge` is scanned beside them and is not one of
them.
- **The `linux/arm64` image**, on the assumption that it installs the same Ubuntu packages as
`linux/amd64` and resolves the same lockfile. If that ever stops holding, the scan step is where a
`--platform` pass goes.
- **Vulnerabilities with no fix published upstream.** They are counted in the run summary but drive
nothing and never become an alert, since no rebuild collects a package that does not exist.
## License
By contributing you agree that your work is licensed under [AGPL-3.0](LICENSE), same as the rest of
the project.