# 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 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()`'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=` 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 — `[(scope)][!]: `, 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.