eeeeeeeeeeee #725
No reviewers
Labels
No labels
accepted
bug
dependencies
docker
documentation
duplicate
enhancement
github-actions
github_actions
good first issue
gradle
hacktoberfest-accepted
help wanted
invalid
javascript
not-an-issue
npm
question
rejected
wontfix
Working on it
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Griefed/ServerPackCreator!725
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
COMMITTED RED ON PURPOSE. At this commit `python3 misc/ci-env-reference-check.py` exits 1 with exactly two findings, both naming the live defect in `release-build.yml`'s `news` job: UNUSED: release-build.yml::news::Post to Discord::WEBHOOK_URL UNDEFINED: release-build.yml::news::Post to Discord::NEWS_WEBHOOK_URL so `git checkout <this>` and run it to watch the pin fail before the next commit turns it green. `test.yml` is wired in the same commit rather than after, because a checker nothing runs is not a gate. THE DEFECT CLASS. `81adb1b3d` (ci(News): use different URL for release news webhook) changed `secrets.WEBHOOK_URL` to `secrets.NEWS_WEBHOOK_URL` and both of the script's reads, and left the `env:` KEY as `WEBHOOK_URL`. An `env:` key is one string and the name a script reads is another; a shell resolves an undefined name to the empty string rather than failing, so `${NEWS_WEBHOOK_URL:-}` was unconditionally empty and the guard that exists to tolerate an unconfigured webhook swallowed every release announcement. Twelve days, no Discord post, no red run, and a log line that reads like a deliberate configuration choice. Reproduced before writing anything, with the two environments side by side: env -i WEBHOOK_URL=... -> "No NEWS_WEBHOOK_URL configured - skipping", exit 0 env -i NEWS_WEBHOOK_URL=... -> "would post", exit 0 WHAT IT CHECKS, both directions, over `.forgejo/workflows`, `.github/workflows` and `.forgejo/actions`: an `env:` key a step's own `run:` never mentions (UNUSED), and a name a script reads that is neither declared, nor assigned in the script, nor supplied by the runner (UNDEFINED). FOUR THINGS IT HAD TO LEARN NOT TO REPORT, each measured against the real workflows rather than guessed: - a quoted heredoc (`<<'PY'`) is not shell, so a `$0` in an awk program is not a reference -- but its body usually READS the step's env keys, which is this repository's standard way of keeping a value out of argv. Scanning those bodies for `os.environ[...]`/`os.getenv(...)` took the finding count from 22 to 14 and is what stops the check flagging the correct pattern while missing the bug. - `U="x"; R="y"` and `then VERB="a"; else VERB="b"` are assignments not at the start of a line. An anchored pattern missed both and reported `R` and `VERB` as undefined; the fix is a token-boundary match. - single-quoted strings and `${{ }}` expressions expand nothing. - PowerShell reads `$env:NAME`; its bare `$name` is a local. The 14 survivors are all one honest shape -- a name consumed by a TOOL rather than by the step's script -- and are allowlisted individually in `misc/ci-env-reference-allowlist.yml`, each citing the file and line where the read actually happens (Gradle's `ORG_GRADLE_PROJECT_*` mapping and `serverpackcreator.publishing-conventions.gradle.kts:38/51/52/60`, semantic-release's `CI`, `@EnabledIfEnvironmentVariable(named = "GRINDER_DOCKER_IT")`, `assemble-docs.sh:36/41`, and the clientside commands' `CURSEFORGE_API_KEY`), so an exemption can be re-checked instead of inherited. Unlike `misc/ci-duplication-check.py` this one is at zero findings once the next commit lands, which is what makes it a `test.yml` step rather than a broken build. The step installs pyyaml only if the runner image lacks it: pyyaml is not in the standard library and every other python in these workflows gets by on `json` alone, so nothing here has ever proven it is present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's pin green, and changes what the announcement is ABOUT. THE BUG. `news`'s step declared `WEBHOOK_URL: ${{ secrets.NEWS_WEBHOOK_URL }}` and its script read `$NEWS_WEBHOOK_URL`. The name never reached the environment, so `[ -z "${NEWS_WEBHOOK_URL:-}" ]` was unconditionally true and every release since `81adb1b3d` printed "No NEWS_WEBHOOK_URL configured - skipping" and exited 0. The env key is now `NEWS_WEBHOOK_URL`, matching the secret and both reads. An absent `NEWS_WEBHOOK_URL` now FAILS the job instead of skipping it. The tolerance is what hid this for twelve days: a secret that is supposed to be configured and is not is an operator error, not a supported configuration, and the release pipeline has to say so out loud. `WEBHOOK_URL` stays optional in `qodana.yml`, which is a different channel and a different secret. ANNOUNCES THE GITHUB RELEASE NOW, not the Forgejo one. Griefed's call: downloads are to come from GitHub, because the twelve assets are the bulk of what this project serves and GitHub's bandwidth is free where git.griefed.de's is not. So the message links the GitHub release, carries a Download field pointing there and asks for it in words, and keeps a Forgejo link for the full notes -- which is not decoration, the mirrored copy is truncated at GitHub's 125,000-character limit and a beta or final release passes it. That means the announcement must not precede the release it sends people to, so `mirror` joins `needs:` -- reversing the call recorded in `.claude/rules/ci-workflows.md`, which had `news` omit it so a stalled push-mirror could not silence a complete release. THAT CONCERN IS STILL RIGHT, so `mirror` is in `needs:` for ORDERING ONLY and is absent from the `if:`, which is `always()` plus explicit `success` checks on `prepare`, `release`, `maven` and `docker`. Two reasons, both already on that page: a red `mirror` does not mean an incomplete GitHub release (9.0.0-alpha.6 uploaded all twelve assets and then died on a step that no longer exists, and `push_mirrors-sync` is best-effort because one dead remote returns 500 and masks a healthy one); and a SKIPPED job cannot be re-run alone, so a plain `needs: mirror` would make the documented per-job repair leave the announcement permanently unsendable. GUARD VERIFIED BY EVALUATION, NOT BY READING. `needs.<job>.result` in a job-level `if:` had no precedent in `.forgejo/workflows`, and this repository has already shipped a guard (`github.event.created`) that was silently inert on Forgejo. Run against Forgejo's own act fork, `code.forgejo.org/forgejo/act` v1.37.0's `pkg/exprparser` -- note it declares its module path as `github.com/nektos/act`, so resolving it needs a `replace` directive: all five success .................................. true mirror FAILURE, other four success ................ true <- the point mirror skipped, other four success ............... true maven / release / docker failure .................. false everything cancelled .............................. false EMPTY needs context ............................... false <- fails CLOSED The last line is the one worth having: an inert context skips the announcement rather than announcing nothing. The verdict is asked of GitHub rather than inferred from a job's colour: a new first step does one `GET /releases/tags/<version>` and fails if the release is absent or carries zero assets -- the asset loop runs after the release is created, so a release with no assets is a half-finished mirror wearing the same green as a finished one. Deliberately not a poll: `mirror` creates the release synchronously, so a 404 means it was never created rather than not yet visible, and the error names the job to re-run. The step also yields the release's own `html_url`, so the message cannot advertise a URL assembled from a version string. FIXED IN PASSING: the footer icon was truncated to `https://git.griefed.de/Griefed/ServerPackCreator/ra` and has been dead since the job was written. Measured -- that URL answers 404, the replacement (`.../raw/branch/main/img/app_128x128.png`) answers 200 image/png, as do the other two embed images. PAYLOAD VERIFIED. The builder was extracted from the step and executed against fixture values for both channels: valid JSON, 1,659 bytes, embed url and Download field both the GitHub release, title "New Griefed/ServerPackCreator Release" and "... Pre-Release" respectively, content 279 and description 278 characters against Discord's 2,000/4,096 caps. End-to-end proof needs a real tag -- `release-build.yml` is tag-triggered only and cannot be dispatched -- so the next prerelease's job log is where to confirm it, by the absence of the "No NEWS_WEBHOOK_URL configured" line. NOT FIXED, pre-existing and unrelated: `misc/ci-duplication-check.py` reports 3 unexplained duplicates and one stale allowlist entry on `develop` too, unchanged by this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Eight files that only made sense while the overview was its own repository. It is built and served by `docs.yml` and the homepage image from here on, so none of them has a consumer. .gitlab-ci.yml GitLab CI. The repository left GitLab; SPC's CI is Forgejo. .github/workflows/ a GitHub Pages deploy and a release workflow. Note they were already INERT here rather than merely unused -- GitHub reads `.github/workflows` only at the repository ROOT, and this is a subdirectory. .github/dependabot.yml and this one is worth naming, because it is the cause of a mess rather than a leftover: it set `target-branch: "dependabot"` for all three ecosystems, so eight of the ten open PRs on the mirror target a side branch nobody has ever merged and could never reach `main` by design. `renovate.json` was present at the same time, aimed at `main`, which is why five of those eight ask for versions `main` already carries. Two update bots, one pointed at a dead branch. .github/FUNDING.yml a GitHub sponsor button; SPC has its own at the root. renovate.json the other bot, for a repository being archived. Dockerfile built a standalone nginx image of the overview. Superseded: the SPA is copied into the homepage image at /config/www/addons. root/defaults/default that image's nginx site-conf. ONLY SAFE BECAUSE THIS IS A HAND-OVER. A deletion inside a living subtree is a conflict on every future `git subtree pull --squash`, which is exactly why `claude-docs/BACKLOG.md` defers the homepage's build-tool migration to the website's own repository instead of doing it in the prefix. Upstream here is archived and there will be no pull. KEPT, against what the written plan said: `.run/`. The plan listed the IDEA run configurations for deletion; `serverpackcreator-homepage/.run/` carries exactly the same kind of file and is kept, so deleting these would make the two sibling web modules inconsistent for no gain. `.vscode/` is kept on the same reasoning -- editor configuration is not upstream infrastructure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`npm audit` on the tree as imported: 85 findings, 12 critical, 39 high. It had not been touched since 2023-09-07. Measured on Node 22.23.3 / npm 10.9.9, before and after: before critical 12 high 39 moderate 27 low 7 total 85 after critical 5 high 28 moderate 16 low 5 total 54 Three changes, no dependency UPGRADES among them -- that is the next commit. OVERRIDES PINNING THE TWO TRANSITIVE PACKAGES THE MIRROR STILL HAS OPEN SECURITY PRs FOR. Both arrive through `@quasar/app`, so there is no direct dependency to bump and `overrides` is npm's supported answer: webpack 5.68.0 -> 5.112.0 closes PR #108, CVE-2023-28154 loader-utils 1.4.0 and 2.0.2 -> 1.4.2 and 2.0.4 closes PR #63, CVE-2022-37601/37603 SCOPED PER MAJOR, `loader-utils@^1` and `loader-utils@^2`, and that is not fussiness. A first attempt used a blanket `^2.0.4` and collapsed both entries to 2.0.4 -- but `babel-loader` declares `^1.4.0` and `vue-style-loader` `^1.0.2`, and loader-utils v2 changed `getOptions()`. 1.4.2 is the patched 1.x and is exactly what PR #63 asked for, so each consumer stays on the major it was written against. `npm audit fix --package-lock-only` for the rest: transitive resolution only, no change to any declared dependency. AXIOS DELETED, dependency and boot file both. `src/boot/axios.js` was untouched Quasar scaffolding -- it created a `github` instance against api.github.com and hung `$axios` and `$github` on the app -- and nothing in `src/` references any of the three. The whole overview is a hard-coded list of three plugins in `AddonIndex.vue` and makes no HTTP request at all. It was shipping axios@0.27.2, which carries more advisories than any other package in the tree, into the browser bundle for nothing. `quasar.conf.js`'s boot array goes to `[]` in the same commit, or the build breaks on an entry pointing at a file that is gone. Measured: axios no longer appears in any bundled js, and the vendor chunk goes 243.57 KB -> 203.77 KB. FIXED IN PASSING: `v-for="addon in addons" v-bind:key="addon"` in `AddonIndex.vue` keyed on the object, which Vue 3 warns about; it is `addon.name` now, which is unique across the three entries. VERIFIED BY BUILDING, which is what the plan made the gate: `rm -rf node_modules dist && npm install && npx quasar build` succeeds on Node 22.23.3 both before and after, so the baseline is a real comparison rather than an assumption. What is left is 5 criticals, all in the `@quasar/app` build chain and none of them reaching a browser -- the next commit is about those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Continues the previous commit, which pinned what could be pinned without upgrading anything. This upgrades, and it clears the rest: as imported critical 12 high 39 moderate 27 low 7 total 85 after pinning critical 5 high 28 moderate 16 low 5 total 54 this commit critical 0 high 18 moderate 16 low 2 total 36 `@quasar/app` 3.3.3 -> `@quasar/app-webpack` 3.15.1. The old package is formally deprecated and names its own successor -- `npm view @quasar/app@3.3.3 deprecated` returns "Should switch from @quasar/app v3 to @quasar/app-webpack v3 instead, which is the direct (and non breaking-change) continuator". Four of the five remaining criticals were in that package's dependency chain (`ejs`, `minimist`, `ouch` and the package itself) and none of them ever reached a browser; they are gone with it. `quasar` 2.7.7 -> ^2.35.2 is the one that did reach a browser: four advisories apply at 2.7.7, patched at 2.22.0, 2.23.3 and 2.32.2. Two of them are SSR-only and this is a hash- mode SPA, but the prototype pollution in `extend()` and the DOM clobbering in `openURL()` are not, and this page is a list of external links. THREE DEPENDENCIES CAME WITH IT, each because the build SAID so rather than because a newer version existed. The sequence is worth keeping, since it is the argument for building rather than resolving: 1. quasar 2.35.2 alone -> COMPILATION FAILED, "Can't resolve core-js/modules/ es.set.difference.v2.js" and three more. Babel transpiles quasar's source against core-js, and 3.31.0 has no module for ES features quasar now uses. core-js 3.31.0 -> ^3.51.0. 2. Still failed, now differently: "export 'toValue' was not found in 'vue'", and 'hasInjectionContext' likewise. Both are Vue 3.3 APIs and vue was pinned at 3.2.47 -- which is precisely what the mirror's Dependabot PR #122 was asking for, two and a half years ago. vue 3.2.47 -> ^3.5.44, and vue-router 4.2.2 -> ^4.6.4 and @quasar/extras 1.16.6 -> ^1.18.0 with it, both kept inside their current major (vue-router 5.x and @quasar/extras 2.x are new majors and are NOT taken). 3. Build succeeded. VERIFIED BY RENDERING, NOT ONLY BY BUILDING, which is the gate this project has already paid for once: `claude-docs/BACKLOG.md` records the homepage's Vite migration passing `quasar build` and then throwing `cardImage is not a function` nine times in the browser. So the built SPA was served and loaded headless, and the DOM contains all three entries -- ServerPackCreator Example Plugin, NekoDetector, jNeedle -- with the layout and Quasar components intact and no page errors in the console. The 36 that remain are build-time only: nothing left in the audit reaches the browser. The five packages that ship are quasar, vue, vue-router, core-js and @quasar/extras, and all five are current here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The curated plugin listing, until now its own Quasar repository published to GitHub Pages at griefed.github.io/ServerPackCreator-Addons-Overview and reached through the addons.griefed.de redirect. It is `src/pages/AddonIndex.vue` here. WHY A PAGE AND NOT A SUBTREE. The import was first done the other way -- squashed subtree, its own build job, its own image wiring -- and the measurement that killed it is the thing worth recording: the ENTIRE overview is one object literal of three entries, there is no API call anywhere in it, and the whole source tree is ~550 lines across 11 files. Carrying a second npm project, a second lockfile, a second Quasar CLI, a second node_modules and a second CI build on every docs.yml run, to render three rows inside a Quasar app this repository already builds, is cost with no return. Griefed's call, and it is the prior question the conventions ask for: "should this code exist?" is cheaper to answer than "how do I make its build cheap?". FOUR CHANGES TO THE PORTED COMPONENT, each because this layout is not that one: - its root was `<q-page>`; `MainLayout.vue` already renders `<router-view/>` INSIDE a `q-page`, and nesting them is invalid, so the root is a plain `<div>` now. - `this.$q` in the template became `$q`, which is the form the homepage's own components use and the one Quasar injects. `this.$q` stays in `viewInfo()`, where it is correct Options API. - the info dialog told the reader that "a curated list of plugins can be found at addons.griefed.de", from inside that list. Removed. - the nav entry is a `<router-link>`, not the `<a href>` the sibling Help and API entries use, because this one is an in-app route and does not need a page load. `/addons` WORKS ONLY BECAUSE `public/addons/index.html` EXISTS, and that is the part to not "simplify" later. The router is in `hash` mode (`vueRouterMode: 'hash'`), so the route is `/#/addons` and nginx answers a bare `/addons` with a 404. The stub is a static file rather than an nginx rule for the reason `docs/assemble-docs.sh` already documents about `/help/latest`: the LSIO base image declares `/config` a VOLUME and seeds its site-conf onto it ONCE, so a redirect baked into the image works on a fresh volume and silently does nothing on a host that already has one. Quasar copies `public/` to the site root, so the stub lands beside `help/` and `api/` with no Dockerfile or workflow change at all -- this commit touches neither. VERIFIED BY RENDERING, headless against the built SPA, because `quasar build` going green is not evidence the page works -- `claude-docs/BACKLOG.md` records this module's Vite migration doing exactly that and then throwing `cardImage is not a function` nine times in the browser: /#/addons 3/3 entries, inside the layout, nav link present /addons/ 3/3 -- the stub redirects and the route resolves /addons 301 -> /addons/ / homepage unchanged, 0 addon entries, nav link present HELP.md's two links now point at serverpackcreator.de/addons. Edited at the repository ROOT: the `serverpackcreator-help/Writerside/topics/` copy is generated and gitignored, and editing it is undone by the next build. Still operator work, outside this repository: repoint the addons.griefed.de redirect at https://serverpackcreator.de/addons, and archive the upstream repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Run 1229, job `build`, step *Verify the workflows' env references*, exit 1: error: externally-managed-environment × This environment is externally managed ╰─> To install Python packages system-wide, try apt install python3-xyz ... note: ... You can override this ... by passing --break-system-packages. hint: See PEP 668 for the detailed specification. The step was added one commit after the check itself and guessed at the runner image twice over: first that pyyaml might already be there, then that `pip3 install` would supply it if not. Both halves were wrong in the same direction -- it is NOT there, and pip will NOT install it without the override. MEASURED IN THE IMAGE THIS TIME, not reasoned about. `docker run --platform linux/amd64 ghcr.io/catthehacker/ubuntu:runner-latest`: python3 -V Python 3.12.3 pip3 -V pip 24.0 python3 -c "import yaml" ModuleNotFoundError: No module named 'yaml' pip3 install --break-system-packages pyyaml -> pyyaml 6.0.3 and then the whole step, with this repository mounted into that container, exits 0 on `ci-env-reference-check: clean (14 allowlisted)`. `--break-system-packages` is what the refusal itself names, and it is safe here for the same reason the flag is frightening anywhere else: the "system" is a job container that is destroyed when the job ends. The alternatives were worse -- `apt-get install python3-yaml` wants an `apt-get update` first, and a venv wants `python3-full`. WHAT THIS COST AND WHAT IT DID NOT. The gate is a lint, it runs before the long build, and `./gradlew help` had already passed, so the only damage was a red `build` job on a commit whose code was fine. The `Docker image` job was green. But a lint that cannot run must not be able to fail a build about something other than its subject, which is exactly what happened, so this is a fix and not a tidy-up. The general form is already on the record in .claude/rules/ci-workflows.md and this is another instance of it: a workflow that merely parses is not a workflow that runs, and the only test that finds this class is a real run on the real instance -- or, as here, the real image pulled locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The listing still named the plugin landscape of 2023: an Example Plugin "for use in version 4.x", plus NekoDetector and jNeedle, both of which live in repositories of their own that have not moved since. The only plugins currently known to work are the four that ship with every ServerPackCreator release, so those are the four rows now -- grinder, selfextract, example and servertest, each pointing at its module under `tree/main/` and at the release its jar comes from. EXPERIMENTAL IS SAID PER ENTRY, NOT ONCE AT THE TOP. The first draft put it in a banner over the whole list, which Griefed rejected for a reason worth keeping: the list is curated, not ours, and the moment somebody else's plugin is added a page-wide "these are all experimental" is simply false. So the sentence lives in three of the four descriptions -- and NOT in the example plugin's, because that one is an example rather than something anybody runs in anger. The wording is checked against the artifact rather than remembered: release 9.3.1 publishes `serverpackcreator-plugin-{grinder,selfextract,example,servertest}-9.3.1` `_experimental.jar`, which is where the `_experimental` in the text comes from. All five URLs the page now links -- four module trees and the releases page -- answer 200. Also carries Griefed's own edits to `MainLayout.vue` and the entry-template comment in `AddonIndex.vue`, both uncommitted when this was written: - the wordmark is a `<router-link to="/">` at the head of the toolbar instead of an `absolute-center` image, so it is a way home rather than decoration, and the `q-toolbar-title` after it becomes the spacer that pushes the three entries right. - all three entries now lead with their icon; Help used to and the other two trailed. - `text-h6` labels become `text-bold text-subtitle1`, and the links become `text-bold text-subtitle2 text-uppercase text-blue-12` with `->` prefixes and shorter text: "Help, Tips, Tricks and more", "API JVM Docs", "Overview of available plugins". - vertical separators between the three. - the commented-out `objectName` block documenting an entry's shape, which is what the next person adding a plugin will copy. VERIFIED on the production build, not the dev server: `eslint` clean over `src/`, `quasar build` succeeds, and the built SPA served and loaded headless gives 4/4 plugins and 3 experimental hints at `/#/addons` and at `/addons/` through the redirect stub, 0 and 0 at `/`, with the reworked header present on all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`serverpackcreator-help` existed to host the Writerside webhelp when GitLab Pages went away and Forgejo had no equivalent. serverpackcreator.de serves that help now, at /help/latest, so the image had no reader left but one: `assemble-docs.sh` pulled it per version and extracted /config/www with `docker create` + `docker cp`. What that cost, measured against Docker Hub on 2026-10-19: 912 tags of a ~62 MB image, pushed to ghcr AND Docker Hub on every push to main/alpha/beta/develop and every tag, built `no-cache: true` so the layers barely dedupe. The assembly pulled 62 MB per version to take 4.2 MB of webhelp out of it and `rm -rf`'d the ~20 MB Dokka tree it also carried, because that tree is labelled `dev` for anything built before the `-Pversion` fix while the release asset is correct. THE HELP IS NO LONGER VERSIONED; THE API REFERENCE STILL IS. /help/latest holds whatever the build that produced the site image rendered. /api/<version>/ stays per version, because it is tied to a compile -- somebody writing against serverpackcreator-api 9.1.0 needs 9.1.0's reference -- and every release publishes one as a dokka-html.zip asset. Nothing on the site ever linked a per-version help URL: MainLayout.vue links /help/latest and always has. Griefed's call, asked and answered before any of this was written. The alternative considered was publishing the webhelp as a release asset so older renderings stayed fetchable, which `assemble-docs.sh` had explicitly ruled out -- on the premise that the image already held a per-version copy. Removing the image removes the premise, so the prior decision was re-opened rather than inherited. `assemble-docs.sh` now takes the bundle from its caller (SPC_WEBHELP_ZIP, mandatory via `:?`) and needs no Docker daemon at all, which is what makes it testable. PINNED FIRST, AND OBSERVED RED. `docs/assemble-docs.test.sh` was written against the new behaviour and run against the UNCHANGED script: 10 passed, 11 failed. Two of the ten passed for the wrong reason and are worth naming -- "the script succeeds" and "the webhelp is served once, at help/latest" were green because the old script really did pull griefed/serverpackcreator-help:9.1.0 and symlink help/latest at it. The assertion that discriminated was "the whole bundle is unpacked, not one file", which looks for the fixture's own HELP.html. After the change: 21 passed, 0 failed, no daemon and no network beyond loopback. The guard's teeth were then checked by mutation -- restoring the per-version shape, - rm -rf "${OUT:?}/help/latest"; mkdir -p "$OUT/help/latest" + rm -rf "${OUT:?}/help/$LATEST"; mkdir -p "$OUT/help/$LATEST"; ln -sfn "$LATEST" "$OUT/help/latest" fails "no per-version help tree is produced" and nothing else, which is exactly the regression this commit exists to prevent. VERIFIED AGAINST REAL CONTENT, not only fixtures: the 64-file webhelp was extracted from the last published griefed/serverpackcreator-help:9.2.1, re-zipped, and run through the new script against the live release assets. Result: help/latest with 64 files, api/{develop-abc1234,9.2.1,9.1.0}, api/latest -> 9.2.1, 44 MB, and both of the site Dockerfile's gates (help/latest/config.json, api/latest/index.html) satisfied. That run also confirmed the seeding step is load-bearing -- without a pre-seeded api/<version>/ the unreleased version 404s and fails the build, loudly. `npm test` was a stub that echoed and exited 0; it is now that test, and `homepage-build` runs it BEFORE the Quasar build so a broken assembly fails in seconds rather than after the SPA. Also removed with the job: docker/Dockerfile.help, its dependabot comment (three Dockerfiles now, not four), and the references to it in pages.yml and docs.yml. SPC_WEBHELP_ZIP is allowlisted in ci-env-reference-check, and that allowlist now cites the reading SYMBOL rather than a line number, which the file's own header had promised and which goes stale on the next edit to the script it points into. Gates on the final tree: ci-env-reference-check clean (15 allowlisted), all workflow YAML parses, ci-duplication-check unchanged from HEAD (3 stale entries, 3 unexplained duplicates -- all pre-existing, from the actions/checkout v6 -> v7 bump re-fingerprinting three allowlisted steps; fixed separately). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Griefed's call: the site image is tagged per release, so the documentation inside it is that release's by construction. An older release's documentation is read from the image tagged for it, and an older API reference is also a dokka-html.zip release asset. The site needs neither an archive nor a manifest saying which versions to serve, so both are gone. Removed: - `docs/versions.json`, the hand-edited list of versions to serve. It was a second place for a release to be recorded and could therefore be stale -- it was: it still named 9.2.1 as latest on 2026-10-19, after 9.3.0 and 9.3.1 had shipped, so the next release would have dropped 9.3.1 off the site. The image tag already carries that fact. - the release-asset downloads it drove, and with them `curl` and `node` as dependencies of `assemble-docs.sh`. The script now fetches nothing. - `homepage-build`'s manifest check, which had nothing left to check. - `homepage-image`'s hand-rolled `tar -xzf` into `docs-build/api/$V`. Both archives now go to the script as `SPC_WEBHELP_ZIP` and `SPC_DOKKA_TARGZ`, so the layout is asserted in one place that `npm test` exercises rather than in workflow shell that nothing can run. - the version from the help's API link. `spch.tree`'s Dokka entry is rewritten to `/api/latest/index.html`, and the TOC check reads that back out of the built zip exactly as before. `writerside` consequently reads nothing from the `version` job and no longer waits on it. /help/latest and /api/latest are now real directories, not symlinks into versioned ones -- there is nothing versioned left for them to point at. The site Dockerfile's gate is unchanged and still the backstop. PINNED, AND THE PIN HAS TEETH. The test was rewritten alongside the script, so its 21 assertions are not the previous commit's renamed: they cover a tree with no versions in it, a rebuild REPLACING rather than merging into the previous tree, and six failure directions. Observed red by mutation rather than asserted to work: - rm -rf "${target:?}"; mkdir -p "$target" + mkdir -p "$target" fails exactly "a stale help page does not survive" and "a stale api page does not survive" -- the staleness this one-version-per-image design exists to make impossible -- and replacing the sentinel assertion with `: "$sentinel"` fails exactly the two guards for an archive that unpacks without the file the site links. 19 passed / 2 failed in each case, nothing else moving. It also needs no network, no Docker daemon and no credentials, which is the same claim the script now makes for itself -- so running it is the evidence for that claim. Verified under bash 3.2 (/bin/bash on macOS), not only the test's own `env bash`, because that dialect is the stated constraint. VERIFIED AGAINST REAL CONTENT: the 64-file webhelp from the last published serverpackcreator-help:9.2.1 and the real 1,411-file Dokka tree from 9.2.1's dokka-html.zip release asset, re-packed as the two artifacts CI hands over. Result: help/latest 64 files, api/latest 1,411 files, 24 MB total -- against 44 MB for the three-version tree it replaces -- and the site Dockerfile's gate satisfied. Gates: ci-env-reference-check clean (14 allowlisted; its two entries are re-keyed to the renamed step), all workflow YAML parses, ci-duplication-check unchanged from HEAD (3 stale / 3 unexplained, all pre-existing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three of its seven entries were stale, and the check was reporting each of those repeats twice -- once as STALE ALLOWLIST ENTRY and once as DUPLICATED x2/x6 -- which reads as six findings and is three. Nothing about the repeats changed; their fingerprints did. The allowlist is keyed by a hash of the step's normalised body precisely so that editing an exempted step re-opens its exemption, and the `uses:` SHA-to-tag conversion edited the body of every allowlisted step carrying a `uses:`: 8e11ca619330 -> 1dff9f0139e0 actions/checkout, fetch-depth: 0 (v6 -> v7, and now six jobs rather than seven: winimage moved into artifacts-reusable.yml) d8342dbff323 -> 80fcca20e6f9 test.yml's Build image / Build image (retry) 5301067150ae -> 3c3b829cfd54 release-build.yml's Build and push / (retry) Each reason was re-read against the step it now names and still holds, so the text is carried over rather than rewritten; the two cross-references between the retry entries are updated to the new keys. Pre-existing, and not from the help-image work in this branch -- confirmed by running the check in a detached worktree at that branch point, which reports the identical 3 stale / 3 unexplained. It was invisible because this check is not a `test.yml` step and so runs only when somebody runs it. before: 103 distinct step bodies, 3 duplicated and unexplained, 4 allowlisted (plus 3 stale entries) after: 103 distinct step bodies, 0 duplicated and unexplained, 7 allowlisted The count reaching zero is the condition `.claude/rules/ci-workflows.md` records for promoting this check to a `test.yml` step. Recorded there as met and still unwired -- that is a decision about what blocks CI, not a cleanup to slip in here. The landmine that caused this is recorded in both that file and the allowlist's own header: a sweep across every workflow must re-key in the same commit, or the gate goes red in a way nobody can tell apart from a real finding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Document at the class, method or function level; add an inline comment only where it explains complex logic, non-obvious behaviour, an assumption, a constraint, a side effect or a design decision that cannot readily be inferred from the code. Prefer clear naming and simple structure over explanatory prose, and never restate self-explanatory code. THE POLICY SUPERSEDES TWO RULES THAT SAID THE OPPOSITE, so they are replaced rather than left to contradict it: "comment everything - every function, method and exported constant carries a doc comment, unexported ones too", and "a doc that only restates the signature is barely better than none BUT SILENCE IS WORSE", which existed specifically to justify writing restatements. `Definition of done` item 2 loses its blanket "on every new/changed unit" for the same reason. `reportUndocumented` goes to false with it, because CLAUDE.md must not describe a build that disagrees with it. DATED HISTORY COMES OUT OF COMMENTS, fifteen sites across the five Forgejo workflows, `gradle/libs.versions.toml`, `qodana.yaml`, two misc scripts, the two windows-runner scripts and four Kotlin doc comments. Each says the mechanism a reader can act on instead. Three ISO dates are deliberately KEPT because they are not history: two in `build-appimage.sh` quote the version string a JDK prints, which is the input being parsed, and `misc/repair-9.0.0-alpha-tags.sh` reasons about which tag was created when. The truncated-manifest incident was told in three files at once; each now carries only the mechanism, and none repeats the host it was reported from. THREE DORMANT GUI FEATURES KEPT AS COMMENTED-OUT CODE ARE DELETED - 21 blocks, 284 lines - and recorded in claude-docs/BACKLOG.md as B49 (the install4j in-app updater), B50 (the font-family picker) and B51 (the Java-executable path setting), with the context to revive each. Nothing live referenced any of it. 1,401 `@param`/`@return` BLOCK TAGS ARE INLINED, across 191 files, in three shapes chosen per declaration: a constructor or data-class `@param` moves onto the parameter it names as per-parameter KDoc (what CLAUDE.md already prescribes, since a class-level block "leaves the properties themselves undocumented as far as dokka is concerned"); 180 redundant tags are deleted outright; the rest fold into the prose. `@throws` (139) and `@see` (17) are untouched - they document what a signature cannot convey. 184 `@author`-ONLY DOC BLOCKS ARE RESOLVED: 39 on overrides are deleted, which fixes a real defect rather than merely removing noise - a present-but-empty KDoc SHADOWS the inherited one, so `FabricMeta.update()` rendered blank instead of inheriting `Meta.update()`. The other 145 got a real one-line description with `@author` kept. Every third-party credit survives: Rob Camick, rgd, LogicBig, whitebear60, Per Cederberg, Hiroshi Miura and Frederic Lavigne are all still present and no file lost its attribution, checked per file and per name against the base. FOUR DEFECTS WERE INTRODUCED BY THE TOOLING AND CAUGHT BEFORE LANDING, named because three of them were invisible to the test suite - nothing executes a doc comment: - KDocAttachmentTest caught a second KDoc added above two Tetris parameters that already had one; dokka drops a stranded block silently. Scan: 2 sites, now zero. - `Source.kt` closes a KDoc with code on the same line (`*/(private val archiveType…`), which the fold mangled. Reverted and written by hand; it is the only file of that shape. - The fold hoisted the CONTINUATION lines of multi-line `@throws` into the description, truncating the tag - "…can not be" with "parsed into integers." stranded above it. Found by comparing every non-param tag's full text against the base rather than by reading: 10 tags in 7 files, all repaired, and that comparison now reports zero altered tags. - The first attribution pass dropped 31 third-party `@author` lines; 17 were restored by the same base-diff, and the remaining 14 were per-member duplicates on overrides whose class-level credit is intact. Verified: 2,205 tests across all eight modules, 0 failures, 28 skipped (the documented gated ones); both CI conventions checks clean; `:serverpackcreator-api:dokkaJavadocJar` still builds; zero `@param`/`@return`, zero empty KDoc blocks and zero `@author`-only blocks remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Imports ServerPackCreator-Addons-Overview as a SQUASHED git subtree under serverpackcreator-addons-overview/, drops the upstream repository's own CI and infrastructure, and clears the 17 security advisories the imported tree arrived with. Squashed on purpose, per the landmine in CLAUDE.md: a non-squash subtree pull makes every upstream commit an ancestor of develop, and `.releaserc.yml` uses a bare `tagFormat: '${version}'`, so semantic-release would read their subjects as SPC's own. Checked before merging - the branch adds four commits, all dated 2026-10-19, none older than the import itself, and none carrying a prefix that would mint a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`JavaConfig.testJava` held three defects in six lines, all invisible to the guard that covers it: while (bufferedReader.readLine() != null && bufferedReader.readLine() != "null") { println(bufferedReader.readLine()) } bufferedReader.close() process.destroyForcibly() - `readLine()` three times per iteration: one line tested for null, a second compared against the literal string "null", a THIRD printed. Two lines in three are discarded and the printed one can itself be null. `java -version` emits three lines, so in practice it printed the third or `null`. - `println` from library code, in a class holding a logger, on the path that validates a user's configured Java - so it reached the console of every GUI and CLI user. The four `println`s in `ApiProperties` are the deliberate exception, because that class IS log4j's ConfigurationFactory and has no logger yet. This one was not. - `close()` and `destroyForcibly()` both skipped when `readLine()` throws; the `catch (IOException)` logs and returns false without either, leaking a reader AND leaving a `java -version` process. It now drains a line at a time into `log.debug`, through `use`, with `destroyForcibly()` in a `finally`. `WebUtilities.getResponseAsString` had the same close-on-success-only shape. It declares `IOException` and reads from a socket, so the throwing path is the documented one and the trailing `close()` is skipped exactly when it matters - what leaks is a connection. It has NO caller in this repository, which is the argument for fixing rather than deleting it: `WebUtilities` is exported from `-api`, so the only code that can reach it is an embedder's, and the compatibility policy keeps the signature. WHY THE EXISTING GUARD COULD NOT SEE ANY OF IT: `javaPathsAcceptOnlyValidInstallations` pins accept/reject, which is the contract, and all three defects leave the return value untouched. A guard on the contract says nothing about the body. The new `validatingAJavaPathPrintsNothingToStandardOut` captures `System.out` instead - verified red against restoring the `println` (`JavaConfigTest.kt:70`), green against the fix. A source-text grep for `println` was deliberately not written: rounds 2 and 3 spent their findings on source-text guards reading prose as code, and this one would also have needed an exemption list for `ApiProperties` that goes stale. Also verified and NOT changed: `ThemeManager:222`'s inline `FileInputStream` is closed by FlatLaf (`javap -c` on flatlaf-3.7.2 shows `IntelliJTheme(InputStream)` closing the Reader in try-with-resources); `ServerPropertiesPatch.restore` throwing cannot strand a port, because `ServerLauncher` calls it inside `runCatching` with the release on the next line; all 28 skipped tests map to documented gated ITs with no new silent skip. `./gradlew build` SUCCESSFUL. Reports appended to both audit files (2026-10-21, round 4). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`LicenseAgreementRenderer` fetches a POM licence URL with `URI(url).toURL().readText()`, which opens a stream on the JDK's defaults - and `serverpackcreator-api/CLAUDE.md` states plainly what those are: "The JDK's default connect- and read-timeout is *infinite*, so `url.openConnection()` or `url.openStream()` written anywhere else is a hang waiting to happen." `generateLicenseReport` is on the build path, so a licence host that accepts the connection and then says nothing stalls the build - locally and on CI - with no timeout, no output and nothing to read. The `catch` around it cannot help, because a hang never throws. WHY THE LANDMINE MISSED IT, which is the part worth keeping. That entry is unusually careful: it names the two sanctioned routes, lists its two benign exceptions, and warns that an earlier version of ITSELF overstated its coverage - "A landmine that overstates its coverage is worse than none." It then tells the next reader to grep `-api` and `-app`. That instruction is the hole. `buildSrc` is a separate build, cannot depend on the modules, and therefore cannot use either sanctioned route - so it is the one place guaranteed to need its own answer and the one place the grep never reaches. A rule that names where to look has told you where it does not apply. `readBounded` is that answer, local to buildSrc and taking the `URLConnection` rather than the URL so the timeouts can be asserted without a server. `LicenseAgreementRendererTest` does that over a `file:` URL - verified red against removing the two assignments, green with them, and confirmed to have actually RUN (1 test, 0 skipped) rather than silently not being collected, which is the trap this source set is reached through `jar { finalizedBy(test) }` to avoid. The same two lines held the repository's only undocumented silent catch. Measured across every main source: 39 catches discard their exception and 38 state why - including the `OutOfMemoryError` in `ConfigCheckTimer:76`, which explains in three lines that a pathologically large server-icon must not take the GUI down. This one now says why too: a licence text that cannot be fetched must not fail the build, because the entry still carries the licence name and its URL. `./gradlew build` SUCCESSFUL. Reports appended to both audit files (2026-10-21, round 5). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`UpdateConfig.updateFallback` did this, on the live set: generationConfig.clientsideMods.clear() generationConfig.clientsideMods.addAll(newBlacklist.split(",")) That is the SAME two-step `GenerationConfigTest.regexVariantListsAreNotSharedBetweenReads` already pins against - in the one path that was not fixed when its siblings were. That guard's own doc states the reason: "a caller holding an earlier result silently saw it emptied and rewritten under them, and because the mutation is not atomic a concurrent reader could observe the set mid-clear." IT IS REACHABLE AT RUNTIME, not only at startup. `UpdateDefaultModslistItem` wires `apiProperties.updateFallback()` to an Edit-menu ActionListener, so it runs on the event dispatch thread on a click - while `ConfigCheckTimer` validates from a coroutine every 500ms after a keystroke and `ControlPanel.generate` builds a `PackConfig` on the generation dispatcher. `TreeSet` is not thread-safe, so this is not merely a stale read: a structural modification during another thread's traversal is undefined, and the window spans a whole `split(",")` of the fallback list. `replaceClientsideMods` / `replaceModsWhitelist` build the new set and publish it in ONE assignment to a now-`@Volatile` field, so a reader sees the old complete list or the new complete one and never a partial one. `loadFallbackModsList` / `loadFallbackWhitelist` moved onto the same path. Returning defensive copies from the getters was considered and is WRONG - checked before being ruled out. `ApiProperties.clientsideMods` hands out the live set by design, and `UpdateConfig` and `loadFallbackModsList` mutate through exactly that reference, so a copy would have made the fallback update a silent no-op. The fix had to be atomic publication, not copying. Pinned by `replacingAFallbackListPublishesANewSetInsteadOfEmptyingTheLiveOne`, which asserts IDENTITY - the property that makes the window impossible - and was verified red against restoring the two-step (`GenerationConfigTest.kt:236`). Recorded in `claude-docs/API-BEHAVIOUR-CHANGES.md`: contents and declared type are unchanged, but an embedder holding a reference across an update now keeps the complete old set instead of watching it be rewritten in place. Also checked and left alone: the six `fallback*` sets are handed out live and are never written through, in any module - latent exposure, no active defect, and narrowing their declared types would be source-breaking for no measured gain. `./gradlew build` SUCCESSFUL. Reports appended to both audit files (2026-10-21, round 6). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Eighth and final round over `9.2.1..develop`. New ground: `@Suppress` annotations, the acknowledged duplicate pairs behind them, and the published API spec. No convention violations, no behavioural defects, nothing changed outside the two audit files. The one check with a receipt behind it was the `DuplicatedCode` suppressions - four prompt loops were once copies, two carried that annotation, and "the annotation was sitting on the copy holding the worst bug". All ten pairs were extracted and diffed with the loader name normalised away: FabricLoader vs QuiltLoader differ in one field name and two blank lines; the three installers differ in local variable names. No behavioural drift in any pair. Also clean: no suppression hides a flagged problem (the 243 are dominated by `unused`, 52 of them in -api where an exported symbol having no internal caller is normal), and `api-docs.yaml` carries zero references to the three schemas whose classes an earlier audit found it still `$ref`-ing. ONE CHECK IS RECORDED AS INCONCLUSIVE RATHER THAN REPORTED AS A FINDING. Spec operations (44) against controller method-level mappings (46) looked like two undocumented endpoints. It is not evidence: the parser written to compose a class-level `@RequestMapping` with a method-level one and normalise `{id:[0-9a-zA-Z]+}` got a real path wrong, reporting `/api/v2/modpacksbyserverpack/{id}` as missing when the controller's actual path is `/api/v2/modpacks/byserverpack/{id}` and the spec documents it correctly. The repository already says the answer comes from regenerating the spec, not from parsing annotations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`theContainersOwnHostnameResolvesWithoutANetwork` failed in run 1242 (index; API id 1680), job `containers`, with an assertion message whose console was EMPTY: resolution must get as far as a connect (refused, since nothing listens): ==> expected: <true> but was: <false> Nothing was wrong with hostname mapping. The probe produced no output at all, and the test could not say so, because `wget` reports an unresolved name as `bad address` and "resolved" was asserted as the *absence* of that string - which an empty console satisfies. So the first assertion passed vacuously and the second reported a resolution failure that had not happened. A guard that reads an absence answers the wrong way round on empty output. PIN WRITTEN AND OBSERVED RED FIRST. Against the unfixed guard, forcing the console empty (`val console = output.lines.joinToString("\n")` -> `val console = ""`) reproduces CI byte for byte, including the nothing after the colon, and the `bad address` assertion passes while doing it. Against the fixed guard the same mutation fails first and correctly: the probe printed nothing, so neither assertion below is evidence about name resolution (timedOut=false, exit=1) ==> expected: <true> but was: <false> Two changes. The console is asserted non-empty before anything reads it, and the message names `timedOut` and the exit code, so an exhausted budget is distinguishable from a resolver that answered - previously those were indistinguishable downstream. And the budget goes 30s -> 2 minutes, the figure `capturesConsoleAndNonZeroExitFromARealContainer` already uses with the reason written beside it: the container exits in milliseconds, so the time goes on the daemon's create, start and log-attach calls, which on a loaded host outlast a tight budget. 30s was the outlier among this file's self-exiting probes. THE HOST, measured across the whole batch rather than from the job that went red. This run took 1453s against 645s for the previous run of this workflow, and it is the slowest of the last 118 except one. Its neighbours in the same class took 172.4s and 128.3s; the failing test took 205.5s against its own 30s budget, since everything outside the poll loop is unbounded daemon latency. A STARVED BUDGET CANNOT BE REPRODUCED LOCALLY, which is worth knowing before anyone tries. The console arrives on docker-java's callback thread, not from the poll loop, so a daemon fast enough to deliver the frames during create/start/log-attach passes even a 1ms budget - verified on Docker 29.9.2, where `Duration.ofMillis(1)` still PASSED. Only the empty console reproduces it. Verified green: all 12 tests of `DockerJavaContainerEngineIT` + `ContainerOwnershipIT` on Docker 29.9.2, 42s, which also satisfies the workflow's own `ran >= 10 && !skipped` proof step. The other two absence-reading tests in the file were scanned and are safe - a positive assertion precedes their `none { }` - and that scan is recorded in the module CLAUDE.md so the ordering is kept when a test is added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Reported: a download from the web service is "shown as finished shortly after it is displayed as started", because the file lands in browser memory before being written to disk. That was exactly right, it was one line in each of two pages, and chasing it surfaced five further defects -- three of them from a dependency upgrade, one of which had left the entire web UI non-functional. ================================================================================ 1. THE REPORTED BUG -- the download pages buffered the archive in the tab ================================================================================ `ModPackDownload.vue` and `ServerPackDownload.vue` did: serverpacks.get('download/' + id, { responseType: 'arraybuffer' }).then(response => { const url = window.URL.createObjectURL(new Blob([response.data])) so the whole pack was assembled in the tab before the save began -- the transfer was already finished when the browser reported a download starting. It also cost a copy (usually two) of an archive whose upload limit ships at 5000MB, so a large enough pack could not be downloaded at all, and the browser's download manager never saw the request, so nothing could resume. Both controls are now real anchors (`q-btn type="a" :href`), with `startDownload()` clicking one for the five-second countdown. The href derives from the boot module's `baseURL`, so the API path stays written in exactly one place. Verified in Chrome against the production bundle and the real backend: clicking the anchor transferred all 6,291,691 bytes of a seeded pack with **0 XHR requests and 0 `createObjectURL` calls**, and the transfer does not appear in the tab's network log at all -- the point being that the request is no longer the page's. Also corrects the status line, which read "Download aborted..." after a *successful* click because the handler set `canceled = true` to stop the countdown. THE BACKEND WAS ALREADY RIGHT, which is worth knowing before anyone "fixes" it: the controllers return `ResponseEntity<Resource>` over a `FileSystemResource`, written with `StreamUtils.copy`; there is no `ShallowEtagHeaderFilter`, no compression and no `WebMvcConfigurer`; and Spring answers `Range` requests over a `Resource` return itself, in `AbstractMessageConverterMethodProcessor` (read out of the pinned spring-webmvc 7.0.9 with javap, now pinned by a 206 + `Content-Range` test, and confirmed live: `Range: bytes=100-199` -> `206`, `Content-Range: bytes 100-199/6291691`). ================================================================================ 2. THE SERVER-PACK DOWNLOAD NAMED THE FILE AFTER THE SERVER'S OWN FILESYSTEM ================================================================================ `TaskExecutionServiceImpl` stored `serverPackZipOld.absolutePath` in `ServerPack.fileName`, and the controller appended `_server_pack.zip` to a name `zipBuilder` had already suffixed: attachment; filename="/srv/spc/server-packs/My Pack_server_pack.zip_server_pack.zip" The same field is serialised into `/api/v2/serverpacks/{id}`, so the path leaked to any API consumer too. Nothing reported it because the SPA overrode the name client-side -- a guard rail in one adapter is a reason a bug stays unreported. Fixing it had to come first: serving the browser an authoritative name is what the download fix relies on. Guards observed red first, verbatim: downloadServerPackStreamsArchive expected:<attachment; filename="My Pack_server_pack.zip"> but:<attachment; filename="My Pack_server_pack.zip_server_pack.zip"> theServedNameIsTheArchivesBaseNameNotTheServersPath expected:<attachment; filename="My Pack_server_pack.zip"> but:<attachment; filename="/srv/spc/server-packs/My Pack_..._server_pack.zip"> aServerPackWithoutAStoredNameIsStillServedUnderAUsableName expected:<attachment; filename="serverpack1_server_pack.zip"> but:<attachment; filename="null_server_pack.zip"> `downloadServerPackStreamsArchive` asserted that whole header and had been green for months, because its fixture passed a bare "My Pack" -- a value no production row ever held. A FIXTURE THAT CANNOT OCCUR IN PRODUCTION IS NOT A PIN; its fixture is now production-shaped. Fixed in three places, one rule each: the write site stores `.name`; `ServerPack.fileName`'s getter reports `File(it).name` so rows written before today stop exposing a path through either surface without rewriting stored documents; the controller stops appending a suffix the stored name already carries and falls back to the id rather than serving the literal string "null_server_pack.zip". Three gaps in the server-pack route's guard set were closed while here, all of which the modpack route already had: served bytes, `Content-Length`, and a `Content-Disposition` injection guard. ================================================================================ 3. A DOWNLOAD COULD SERVE THE EXTRACTED DIRECTORY, AND GRIDFS READ EVERY ARCHIVE TWICE ================================================================================ `FileSystemStorageService.load` matched on substring: rootLocation.listDirectoryEntries().find { path -> path.toString().contains(id) } `ConfigurationHandler.isZip` extracts an archive into a sibling directory named after it, so the root holds both `anId.zip` and `anId/`, and `exists()` is true for either. Whichever the listing yielded first was wrapped in a `FileSystemResource` and served. Not theoretical -- the new guard reproduced it on the first run (`expected: <.../storage/anId.zip> but was: <.../storage/anId>`), and with the archive already swept the scan has no right answer available at all. It also matched the whole *path*, and listed the entire storage root on every download. Now resolves `$id.zip`, with a legacy fallback restricted to regular files and to the entry's own name. `StorageSystem.cacheFile` -- the GridFS fallback, which runs inside the request before any byte reaches the client -- copied the archive out of the database and then read the whole file again to SHA-256 it, for a hash it discards. That overload had one caller and no test; it is now `cache(...)`, returning `Optional<Path>` and hashing nothing. Red first: `sha256Of(any<File>())) should not be called`. The guard also asserts the bytes still arrive, so "never hashes" cannot be satisfied by never working. ================================================================================ 4. THE PRODUCTION BUILD SHIPPED A WEB UI THAT RENDERS AND DOES NOTHING ================================================================================ Found by checking whether the download page's stuck loading spinner also happened against the real backend rather than the verification stub. It did. `@quasar/app-vite` 3 defaults `build.vueOptionsAPI` to **false** -- its own defaults block, beside `vueRouterMode` -- compiling `__VUE_OPTIONS_API__ = "false"`, which tree-shakes `applyOptions` out of Vue. Every SFC in `src/` is Options API, so a production build silently lost `methods`, `computed`, `data` and every lifecycle hook. `setup()` still ran, so every page rendered and nothing errored; the SPA simply never issued a single HTTP request. The scaffolded `// vueOptionsAPI: false,` comment is app-vite **2**'s, from when the default was the other way round. Measured against the real stack, before: `found.m === null` (no mounted hook registered), while `isMounted` was true -- Vue sets that before the queued post-render hooks run, so it proves nothing; typeof countDownTimer / startDownload / downloadHref / date === "undefined"; tomcat access log after visiting three pages: ZERO /api/v2/* requests, while a plain fetch() typed into the same page reached the server and was logged. after: the page renders the pack and the log shows `GET /api/v2/serverpacks/7 -> 200`. ================================================================================ 5. THE ROUTER READ ENV NAMES APP-VITE 3 NO LONGER DEFINES ================================================================================ `src/router/index.ts` read `process.env.SERVER` / `VUE_ROUTER_MODE` / `VUE_ROUTER_BASE`. app-vite 3 defines **no** `process.env` replacements at all; it defines `import.meta.env.QUASAR_*`. Dev died loudly (`ReferenceError: process is not defined`, before the router existed). The build failed quietly: the reads collapse to `undefined`, `undefined === 'history'` is merely false, so the hash branch was taken whatever `vueRouterMode` said and `vueRouterBase`/`publicPath` were ignored. It worked only because 'hash' is what this project configures -- THE BROKEN EXPRESSION'S FALLBACK HAPPENED TO EQUAL THE INTENDED VALUE. Measured with `vueRouterMode: 'history'` and no other change, rendered drawer hrefs: before: #/ #/submissions #/downloads #/history #/about after: / /submissions /downloads /history /about `quasar dev` boots again, closing B53. ================================================================================ AUDIT, GUARDS AND VERIFICATION ================================================================================ Defects 4 and 5 came from `053e1efd5` (@quasar/app-vite 2.4 -> 3.10), so the rest of that upgrade was audited deliberately. Cleared: `rawDefine`/`polyfillModulePreload` exist only as commented scaffold; no `#q-app` imports; no App Extension API use; `typescript.strict`/`vueShim`, `distDir`, `allowOutsideProjectDistDir` and both targets are explicit; the Node floor is satisfied (app-vite wants `^22.22 || ^24 || ...`, `quasar-conventions.gradle.kts` pins 24.18.1); and `__VUE_I18N_LEGACY_API__` is moot because i18n is unused scaffolding. The SPA was then exercised against the real backend, which had never been done: every page rendered with zero console errors and zero failed requests, and the Pinia settings store came back populated from the real API (547 clientside mods, 5 modloaders, Aikar's flags). That last assertion is the one worth keeping -- A PAGE THAT FETCHES NOTHING ALSO RENDERS WITHOUT ERRORS, which is exactly how defect 4 survived. New guards: `test/config/quasar-config.test.js` (the Options API flag) and `test/config/no-process-env.test.js` (all of `src/`, comments stripped so prose is not a defect, teeth re-verified by mutation). Both are deliberately NOT under `test/build/` -- `.gitignore` has a repo-wide `**/build/`, so a guard written there is neither committed nor run. `test/install-quasar.ts` now skips without a DOM so a `@vitest-environment node` guard can run. THE LESSON THE UPGRADE TEACHES: its own verification was "37 tests green, `quasar build` succeeds, vue-tsc clean, eslint clean" -- all true, and structurally incapable of seeing any of the three defects, because each lives in a compile-time `define` that Vitest does not apply and a type checker cannot see. A dependency upgrade that changes build-time defaults is verified by running the artifact, not by a green build. :serverpackcreator-app:test 242 tests, 0 failures, 0 skipped (was 234). serverpackcreator-web-frontend: vitest 44 passed (was 38), vue-tsc clean, eslint clean. Compiler warnings unchanged at the recorded baseline of 40 (`:serverpackcreator-{api,app,clientside,grinder}:compileKotlin --rerun-tasks`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Renovate's pin PR wrote `mongodb/mongodb-community-server:8.0.34-ubuntu2204@sha256:0acefb70...` into both compose files. That reference is unpullable: the registry answers `manifest verification failed for digest sha256:0acefb70...`, and the digest is not the tag's manifest list, nor either of its two children: tag 8.0.34-ubuntu2204 -> sha256:62ef7501... (manifest list) linux/amd64 -> sha256:007e55dd... linux/arm64/v8 -> sha256:e7892271... occurrences of 0acefb70 in that list: 0 MongoDB re-publishes patch images under the same tag and Docker Hub collects the unreferenced manifests, so the digest Renovate recorded when it opened the PR had already gone by the time it was merged. A pin is only as good as the moment it was taken -- and `docker/docker-compose.yml` is the file users copy, so this would have broken `docker compose up` for them and for the dev stack alike, with an error naming a digest that appears nowhere. Repointed at the current manifest list rather than a per-platform child, which keeps the image multi-arch. Verified with `docker manifest inspect` that the new reference resolves and that both files still parse under `docker compose config -q`. Every other digest this repo pins was checked the same way and all seven resolve: debian:bookworm-slim, eclipse-temurin:21-jdk-jammy, linuxserver/baseimage-ubuntu, linuxserver/nginx, griefed/gitlab-ci-cd, griefed/serverpackcreator:9.3.0 and mongo-express. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Docker Hub marks `mongo-express` deprecated for maintainer inactivity. Measured rather than taken on trust: the newest tag of the Official Image was pushed **2024-11-21**, the pinned `1.0.2-20-alpine3.19` is Node 20 on Alpine 3.19 and both are now EOL, and the `amd64` page lists no supported architectures. The deprecation notice itself landed in docker-library/docs in Feb 2026. **The project is not dead, which is the part worth knowing before anyone tries to switch back.** `mongo-express/mongo-express` was pushed 2026-10-21, has ~6k stars and is not archived, and npm still carries releases (`1.1.0-rc-4`, 2026-02-23; last stable `1.0.2`, 2023-10-02). What does not exist is a maintained *image*: `mongo-express/mongo-express-docker` has **no releases**, its README only tells you to build one yourself, and there is no `ghcr.io/mongo-express/mongo-express`. So "move to the upstream image" is not an option that exists, and building it here would put a Dockerfile this repo has to maintain into a stack whose whole point is starting fast. Replaced with Mongoku (`huggingface/Mongoku`, repo pushed 2026-10-22, image 2026-09-07, 1.4k stars), which is MongoDB-specific and takes one connection URL in exactly the shape mongo-express took, so the service is the same size it was. DbGate was the other live candidate -- more capable and more active, but a general multi-engine manager configured through a `CONNECTIONS`/`LABEL_`/`SERVER_`/`USER_`/`PASSWORD_`/`ENGINE_` set rather than one variable. Dropping the browser entirely was considered and rejected: the file already points at IDEA's Database tool, but that is **Ultimate only**, so Community Edition contributors would be left with no zero-install option. Checked before committing to it: `2.11.3` publishes linux/amd64 **and** linux/arm64, so it runs natively on Apple Silicon rather than under emulation. Two things only running it could have found, both now in the file as comments: - **`MONGOKU_SERVER_ORIGIN` is required for EDITING, not for links.** Mongoku compares the request origin against it and silently serves a read-only UI when they differ -- which they do by construction here, since it listens on 3100 and is reached on 8081. The first run came up with "To enable write operations, set the MONGOKU_SERVER_ORIGIN environment variable" and every write refused; that would have been a quiet regression against mongo-express, which could edit. - **No volume.** Mongoku persists only its list of servers, to `MONGOKU_DATABASE_FILE`, and `MONGOKU_DEFAULT_HOST` re-seeds it every start, so a volume would buy nothing and would keep stale hosts after this file changes. Published on `127.0.0.1:8081` rather than every interface, because Mongoku has no authentication where mongo-express had basic auth. That auth was `mexpress`/`mexpress` committed in this very file, so it excluded nobody who could read the repository; refusing the LAN is the stronger guarantee and costs a dev-only tool nothing. The port stays 8081 so BUILD.md and muscle memory do not move. Verified end to end against the real stack: `docker compose -f docker/docker-compose-dev.yml up -d` brings both containers up, the database reports healthy, Mongoku binds `127.0.0.1:8081->3100`, starts clean (`Listening on http://0.0.0.0:3100`, no connection or auth errors), and the UI lists `serverpackcreatordb:27017` with its four databases, their collection counts and sizes, and the write controls present. Compose still parses under `docker compose config -q`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`Dev stack` does not build the application container, and that is by design rather than a defect: `docker-compose-dev.yml` keeps the `serverpackcreator` service behind `profiles: [app]`, so Compose leaves it out of the default set. Measured: docker compose -f docker/docker-compose-dev.yml config --services serverpackcreatordb, serverpackcreator-mongoku docker compose -f docker/docker-compose-dev.yml --profile app config --services serverpackcreatordb, serverpackcreator, serverpackcreator-mongoku The reason is in the file's own header and in BUILD.md: that service's builder stage runs a full Gradle build inside the container, so it is for testing the artifact, not for iterating, and the application normally runs from `WebService (dev stack)` with the debugger attached. What was missing is a one-click path to the opt in, which existed only as a CLI command. Adds `Dev stack (with app)`, enabling the profile through a tracked `docker/app-profile.env` holding `COMPOSE_PROFILES=app`. Verified that Compose honours it from an env file and from the environment alike, both yielding the three-service set above. The env file is deliberately NOT `docker/.env`: Compose reads that one automatically, which would opt the plain `up` into building the application image and cost every routine start the thing this stack exists to avoid. The profile is set that way rather than through the IDE's Profiles field because `envFilePath` is the one option key this repository already proves works, in the existing `Dev stack` config. The link I could NOT verify from here is whether IntelliJ forwards that file to Compose as its env file; the JetBrains documentation describes the field as a path to a custom env file, which is what Compose means by one, but that is an inference rather than a measurement. The configuration's comment says so and gives the direct alternative (the Profiles option under "Modify options"), and the check is immediate: if only the database and Mongoku come up, it was not forwarded. Also records that Build defaults to "Only missing images", so the configuration rebuilds on first run and not after later source changes; "Always" is the equivalent of the documented rebuild flag. Two notes on what this does not do. The XML comment avoids double hyphens throughout, because they cannot appear inside an XML comment and the first draft was malformed at the line quoting the command. And nothing here addresses B51: this host's Docker Desktop reports 3917 MB, exactly the figure that entry measured the app image build saturating, so the build is expected to fail until that limit is raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>This module had never had a test for `src/` -- `npm test` was the documentation-assembly script and nothing else, so the only thing between a broken SFC and the published site was a webpack ESLint pass running a config with `eslint:recommended` commented out. Four latent defects had accumulated behind that. They are fixed here, with the suite that can see two of them, because migrating a build on top of them would have made every later failure ambiguous. **The harness**: Vitest, Vue Test Utils and happy-dom, under `test/`. Two notes that each cost a wrong red first. `quasar` is aliased to dist/quasar.esm.prod.js, because its `main` is the SSR-SERVER build whose installQuasar takes an ssrContext and Object.assign()s into it -- every mount died with "Cannot convert undefined or null to object" before reaching an assertion. And Quasar's palette is installed as custom properties on document.body, because `getCssVar` reads them from there and Vitest loads no stylesheet; without it a component that colours itself from the theme renders no colour at all, which is indistinguishable from the first defect below. `"test"` deliberately keeps meaning the documentation assembly -- docs.yml's *Verify the documentation assembly* step runs it. The unit suite is `test:unit`, a separate script, so neither gate can swallow the other. Both guards were written and observed RED before the fixes, and both reds were the missing implementation rather than a broken harness: MainLayout > gives every header icon tile a real background colour expected '' to contain 'background-color' Index — the Features Excerpt cards > animates the hovered card and only the hovered card hovering card 1 should animate exactly one heading: expected +0 to be 1 **1. The header's three icon tiles rendered `background-color:undefined`.** MainLayout binds `:style="'background-color:' + secondaryColour"` on the Help, API-docs and Addons tiles, and the component defined `secondaryColour` nowhere -- its setup() returned only `year`. The declaration was invalid, so the browser dropped it and all three rendered with no background. The guard asserts a colour ARRIVES rather than that the string "undefined" is present, because an invalid declaration is dropped rather than kept. Verified load-bearing: with the palette in place and the one-line fix reverted, it still fails. **2. Seven of Index's nine colour keys were written and never read.** Occurrence counts in Index.vue, which are the evidence that deleting them preserves behaviour because no test can be: primaryColour 13, secondaryColour 2, and `warningColour`, `accentColour`, `darkColour`, `positiveColour`, `negativeColour`, `infoColour`, `bg_color` **1 each** -- a count of 1 being the declaration reading itself. Two of them were also wrong: `getCssVar('dark ')` and `getCssVar('info ')` carried trailing whitespace into the custom-property name and returned null. That cannot be pinned by a behaviour test precisely because the values were dead, which is why the honest answer is deletion. **3. All four Features Excerpt cards animated heading three.** FILES, MODLOADERS, ADVANCED and PLUGINS each called `hoverOver(3)`, so all four wrote `about_heading_3` -- and nothing in the template bound it. The handler ran, the state changed, no pixel moved. Each card now carries its own index and binds its own heading, the way the two sponsor cards already do. The guard drives the clock, because `hoverOutTimeout` clears the animation a second AFTER the pointer leaves and card one was otherwise still animating when card two was hovered. Re-verified by restoring the defect: `hovering card 2 should animate a heading inside that card: expected false to be true`. **4. Index's root was a `<q-layout>` nested inside MainLayout's own.** Quasar provides QLayout by injection, so the inner one shadows the outer for its whole subtree; AddonIndex and Error404 are plain `<div>`s and this module's CLAUDE.md already records why. Fixed before the Quasar bump deliberately -- QLayout sets positioning and a min-height, so this is not guaranteed to be a visual no-op, and carrying it through a 2.11 -> 2.3x bump would give any layout regression two candidate causes. Verified by eye against the live site rather than by a test, which is the standard this module sets for a .vue change: `quasar dev` and serverpackcreator.de scrolled to the same offsets render the same elements at the same positions, the only difference being the header tiles now carrying their colour. `npx quasar build` and the documentation assembly green throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes B49 and B38. The homepage was stuck on `@quasar/app` 3.3.3 -- the pre-rename webpack CLI -- which cannot build `quasar` above ~2.14.5, while CVE-2026-73647 / GHSA-3r53-75j5-3g7j (prototype pollution in `extend()`) is first patched in 2.22.0. There was no bump out of it: `npm audit` reported @quasar/app `critical`, `range=*`, **`fixAvailable=false`**. It now builds on @quasar/app-vite 3.10.3 with quasar 2.35.2, all of `src/` is TypeScript, the linter is eslint 10, and CI runs all three as gates. `npm audit` now reports **0 vulnerabilities**. **B49's prototype stopped at a symptom it could not explain, and this is the explanation.** It recorded nine `cardImage is not a function` throws, no flashcard receiving its styleTop/styleBottom width, and a blank page under `quasar dev`, with the open question being *"why the compiled render's $options-class accesses do not resolve while $data and $setup ones do"*. That asymmetry IS the answer: @quasar/app-vite 3 defaults `build.vueOptionsAPI` to **false** and compiles `__VUE_OPTIONS_API__ = "false"`, tree-shaking `applyOptions` out of Vue -- so `methods` and `computed` vanish while `data` and `setup`, which are neither, survive. The flag is set, with the reason beside it. serverpackcreator-web-frontend's CLAUDE.md had documented the same landmine from its own upgrade a day earlier; two modules hit one dependency default and the second re-derived it from scratch. Two guards ported from that module, each observed red first: src/ > reads build-time configuration from import.meta.env, never process.env expected [ 'router/index.js (4)' ] to deeply equal [] quasar.config.js > keeps the Vue Options API enabled -- red by deleting the flag **The image stops building with a toolchain the lockfile never named.** Its builder stage ran a bare `quasar build`, which resolves through PATH and so invoked a *globally installed* @quasar/cli v2.5.0 (2022) inside griefed/gitlab-ci-cd:2.8.0 -- outside the lockfile and outside Renovate, while CI used the local one. It is now a digest-pinned node:22-bookworm-slim (v22.23.4) running `npm ci` and `npm run build`, which keeps node_modules/.bin first on PATH. node:22 and not 24 deliberately: 22.22.0 is what the old image shipped and is @quasar/app-vite's own floor, which is a hard `process.exit(1)` rather than a warning. That change was made and verified GREEN against the UNMIGRATED tree first, so the first failing image build could only have one cause. **The qflashcard app extension did not have to be dropped.** Its 2.0.0-alpha.4 refuses app-vite 3 (`compatibleWith('@quasar/app-vite', '^1.0.0-alpha.0')`), but 3.0.0 declares `peerDependencies: {"@quasar/app-vite": ">=3.0.0"}`. That also retired a planned workaround: the alpha shipped a `dist/types/index.d.ts` containing `import { String | StringArray } from './types'` -- three TS **parse** errors, which neither `skipLibCheck` nor an ambient `declare module` can suppress, because both gate semantic checking while the file is still resolved and parsed. 3.0.0's typings are valid, so no tsconfig redirect is needed. Check whether the thing you are working around has moved before building the scaffolding. **`@quasar/extras` deliberately STAYS at 1.18.0**: 2.x's exports map has no `fontawesome-v5` or `mdi-v5`, both of which this site requests. 1.18.0 already ships the v7 sets, so the rename-and-bump is available later as one change rather than as a side effect of this one. The rest, all serving the one goal: - quasar.conf.js -> quasar.config.js; the dead ssr/pwa/cordova/capacitor blocks go with it (there is no src-ssr or src-pwa, and their chainWebpack hooks have no app-vite equivalent). - babel.config.js, jsconfig.json and .postcssrc.js deleted; postcss.config.js is ESM; src/index.template.html becomes a root index.html with `<!-- quasar:entry-point -->`. - `"type": "module"`, engines.node `>= 22.22.0`. - 18 `~assets/...` references rewritten to `~@/assets/...` in templates and `url("@/assets/...")` in styles, and five `import('layouts|pages/...')` to the single `@` alias app-vite 3 generates. - The three `require(`assets/serverpackcreator/cards/${...}.png`)` sites become one eager `import.meta.glob` looked up by number. - quasar/wrappers -> `defineRouter`; `process.env.*` -> `import.meta.env.QUASAR_*`. QUASAR_SERVER is substituted as a boolean literal, never the string 'false'. - src/boot/axios.js deleted with its boot entry and the axios dependency: nothing imported it and its baseURL pointed at an unrelated project. @quasar/cli goes too -- it and @quasar/app both declared a `quasar` bin and npm silently kept one. autoprefixer becomes a direct devDependency. - tsconfig.json is extends-only; strictness comes from `build.typescript { strict, vueShim }`, NOT from editing the generated file. No `paths` block is added, and specifically not in the root tsconfig: config inheritance does a shallow assign, so a child `paths` replaces the generated one wholesale and would silently delete `@`, `@/*` and the ten `#q-app/*` entries Quasar emits. - eslint 8.57.1 (end of support since October 2024) -> a flat eslint.config.js on eslint 10 with typescript-eslint and eslint-plugin-vue 10; eslint-config-prettier dropped, since no prettier was installed and no .prettierrc existed, so it turned rules off and formatted nothing. vite-plugin-checker runs vueTsc and the lint inside `quasar build`. - All five SFCs and both router modules become TypeScript. Index.vue's heading state had to be reshaped rather than annotated: `this['about_heading_' + index]` composes a property name dynamically and no typing expresses that against `this`, so the six numbered pairs become two records keyed by card index. The colour stays `string | null` rather than being defaulted, because `getCssVar` returns null for a missing property and narrowing it would change what renders. - CI gains `npx quasar prepare` (the generated tsconfig is gitignored), the lint, the type-check and the unit suite, all before the SPA build, and `npm install` becomes `npm ci`. Verified to have teeth rather than assumed. The type-check, with a throwaway probe: `error TS2322: Type 'ComponentOptions' is not assignable to type 'number'` -- which also proves the flashcard typings resolve. The linter and the build, by planting `const neverUsed = 1`: `'neverUsed' is assigned a value but never used`, reported by `npm run lint` AND failing `quasar build` through the checker. The 1,369 lines no working linter had ever seen came back clean, and `--format json` confirms 16 files were actually inspected, because a lint matching no files also reports nothing. And the part no command answers, against the PRODUCTION bundle, because the define only differs there -- all four of B49's symptoms are gone: each flashcard shows its own image, each has its inline width, hovering one runs its flip transition, and `mounted()` fires. /, /#/addons and a 404 route all render with no console errors. Both build paths exercised: `npx quasar build` and `docker build`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The chip said "GitHub Downloads" and was filled by github-release-stats@1.0.2 -- published 2021-07-12, 39 weekly downloads, five years untouched -- which walked GitHub's releases and nothing else. ServerPackCreator is published to four forges, so the number was roughly 92% of the story. It now reads "Downloads" and counts all three forges that report one. **The library** lives in src/services/downloads/ and hard-codes no URL, so it is useful outside this site. Two entry points, both taking an injectable `fetch` that defaults to `globalThis.fetch` -- which is what makes it isomorphic, Node and browser alike, and testable with no network: countPlatformDownloads('forgejo', 'https://git.griefed.de/Griefed/ServerPackCreator') countAllDownloads('Griefed', 'ServerPackCreator', { hosts: { forgejo: '…' } }) The first derives owner, repository and API root from the URL itself. The second needs somewhere to look, so `DEFAULT_HOSTS` holds github.com and gitea.com -- and `forgejo: null`, because Forgejo is self-hosted and has no canonical instance; a caller supplies one or it is skipped. Gitea and Forgejo share ONE implementation: the fork's endpoint, asset shape and paging are identical, and a second copy is the duplicated-knowledge failure this repository has recorded four times. Per-forge failures are collected into `failures` rather than thrown, so one unreachable forge still yields the others. ServerPackCreator's own four repositories are in src/config/repositories.ts -- site configuration, not library content. **The 39 guards were written first and observed red against stubbed implementations**, which is the sanctioned route for new code: there is no behaviour to preserve, so no honest behaviour-preserving seam commit exists, and the seam lands with the guard instead. Every function body threw `not implemented`, and the reds were that rather than a broken harness -- 24 failing with `Error: not implemented` and 5 with `expected error to be instance of UnknownPlatformError` / `RepositoryUrlError`, because the stub threw a bare Error where the contract names a type. **Two were green against the stub by construction** and are named here because they are the ones that would otherwise be mistaken for pins: `GITEA_PAGE_SIZE` being 50, and `DEFAULT_HOSTS` having no forgejo default. Both assert constants the stub already declared. **`limit`, not `per_page`, and this is the whole reason the Gitea guard was written first.** Gitea and Forgejo do not reject GitHub's parameter name; they ignore it and fall back to the instance's DEFAULT_PAGING_NUM. Measured against the live forges by mutating the one constant: limit=50 per_page=50 gitea 6,385 (343 releases) 907 (10 releases) forgejo 8,546 (387 releases) 2,008 (30 releases) total 189,691 177,675 A 7x and a 4.3x undercount, with no error, no warning and a confident-looking number. Only that assertion can tell the two apart. **A failed forge is retried once, for the same reason.** Found by building the image for real: one forge dropped out during `npm run build` inside the container and the total was written as 183,315, exactly 6,385 short -- gitea's whole figure -- while a `--no-cache` rebuild counted all three and gave 189,700. Nothing failed; the collector is fail-soft, so it logged the forge as unavailable, wrote the lower number and exited 0, and that number went into the bundle. A missing forge does not look like an error, it looks like a smaller total. `attempts` defaults to 2 and is an option, so a caller who wants a single pass can ask for 1; a forge that is genuinely unreachable costs one extra request and still lands in `failures`. Verified red by setting DEFAULT_ATTEMPTS back to 1: retries a failed platform rather than baking a silently low total -- expected 1 to be 2 gives up after the retry and records the failure -- expected 1 to be 2 **Counted at build time, refreshed in the browser.** `scripts/collect-downloads.ts` runs the same library under Node and writes src/generated/download-counts.json, which `npm run build` does before Quasar. That is where the asymmetry pays: Node has no CORS, so git.griefed.de -- which sends no Access-Control-Allow-Origin, Forgejo's default -- is counted there and cannot be counted from a page; no visitor pays the ~20 requests and several MB a sweep costs; and nothing is charged against GitHub's 60-per-hour-per-IP anonymous ceiling, which the old chip burned on every page load. `refreshDownloadTotal()` then updates it in the background at most once a day per visitor via localStorage, every access wrapped because private browsing throws rather than returning null. The generated file is gitignored. A committed copy would be a second place a total is recorded and would go stale, which is what docs/versions.json did before it was deleted. **Fail-soft, verified by pointing every host at a closed port:** the collector logs each forge as unavailable, writes `{"total": null, "platforms": []}` and exits 0. A forge being down must not fail a site build. A null total renders as "many" rather than 0 -- no forge reachable is a different fact from nobody downloading anything, which is the same conflation the old `v-if="downloads === 0"` made when it span forever on a rate-limited visitor. GitLab is absent on purpose and raises UnknownPlatformError naming the reason. It publishes no release-asset download counts at all: `assets.links[]` carries id, name, url, direct_asset_url and link_type and nothing else, and gitlab-org/gitlab#327317 has been open since 2021-04-09. Counting it as zero would be indistinguishable from a repository nobody downloads. CI and the Dockerfile both gained the collector step; the image also moves to `npm ci` and `npm run build`, which keeps the lockfile's own Quasar CLI by putting node_modules/.bin first. `GITHUB_TOKEN` is allowlisted in ci-env-reference-allowlist.yml because Node reads it, not the shell. Verified. 61/61 tests (the 37 pinned red in the previous commit now green), type-check, lint, documentation assembly and `quasar build` all green; both workflow convention checks clean. Against the real forges: github 174,767 / gitea 6,385 / forgejo 8,548 = 189,700 in 5.5s. And against the production bundle in a browser -- the chip renders "Downloads: 189.700" on first paint with **zero requests to any forge API** (39 requests, all local assets plus the two pre-existing shields.io badges), no spinner, and locale-aware separators. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Restoring the hover animation also gave those four headings the sponsor cards' accent tint, and #54a896 on the #ec982f, #f4655f, #9c27b0 and #3b97d1 bands is poor contrast -- a readability regression on a change whose point was the bounce. They now bounce and stay white. `headingColour` is gone rather than narrowed to the two headings that still tint, because six stored colours of which four were never read is precisely the dead state the earlier `refactor(homepage): drop seven data keys in Index that nothing reads` removed. The tint is derived instead: one `hoveredHeading: number | null`, and a `headingTint(index)` the two sponsor headings bind and the Features Excerpt headings do not. Which heading tints is therefore recorded once, in the template, rather than in a second list the handlers would have to agree with. Timing is unchanged: `hoveredHeading` clears on mouseout, so the sponsor tint still reverts immediately while the bounce still finishes over its second. Two guards, both verified by mutation: card 1 should bounce without being tinted -- restoring :style="headingTint(3)": expected 'color: #54a896;' not to contain 'color' tints its heading while hovered and restores it on leaving (new, for the sponsor cards) -- pinning headingTint to the resting colour: expected 'color: #f7f7f7;' to contain '#54a896' The second is the reason the first is not enough on its own: "keep just the bounce" must not quietly take the sponsor cards' tint with it. The flashcard stubs are deleted from these tests. They existed only because the page resolved its images with webpack's `require()`, which `import.meta.glob` replaced in the Vite migration -- the real components render fine now (11 flashcards, 18 images), and the sponsor cards live INSIDE a flashcard, so the stubs were also why the new guard could not see them. The tests now mount the whole page. Suite 61 -> 62. Lint, type-check and `quasar build` green, and confirmed against the production bundle in a browser: hovering MODLOADERS leaves its heading white, hovering INSTALL4J turns that heading teal while INTELLIJ IDEA beside it stays white. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>