Gimme dat! #702

Merged
Griefed merged 29 commits from develop into main 2026-10-07 18:27:46 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-10-06 21:55:37 +02:00
Griefed's call: code documentation explains the current state, for a reader who has never
seen any other version of it. It does not explain what once was, or justify today's shape
against yesterday's.

The documentation this branch added broke that rule throughout, so this removes it from
everything the branch touched -- 220 lines out, 155 in:

  ContainerFixtureBudget            a paragraph tracing 60s -> 90s -> 300s through three
                                    run numbers and a table of workflow durations, where
                                    what a reader needs is "setup, not an assertion; sized
                                    so a loaded host cannot decide a verdict"
  DockerJavaContainerEngineIT       six passages citing runs 681, 719, 726, 834, 954, 956
                                    and an "old form" of an assertion that no longer exists
  ContainerOwnershipIT              the 2026-09-27 interference narrative, and a fixture
                                    doc arguing against a number it no longer uses
  PowerShellInstallerJavaTest       "this probe named /templates outright, so it was correct
                                    only in the container ... red there on 2026-10-02"
  EmbeddedMongoAvailable            run 836's WiredTiger timings, and a constant defending
                                    its own spelling against a key retired in another class
  the four workflows                run 739's login, "the comment that once said so was
                                    wrong", the pull_request doubling measured across a
                                    nine-run batch, and the dind reproduction's numbers

What survives is the part a reader can act on. "BuildKit defaults a mirror to https, so the
second stanza is required or it silently falls back" stays; "three runs died before we
learned that" goes. The test applied throughout: does the sentence change what someone
does, or only tell them a story?

None of it is lost. Every incident, measurement, run number and date above is in this
branch's other seven commit messages, which is where `git log -S` and `git blame` look, and
the durable ones are in claude-docs/ and the module CLAUDE.md files.

The convention is written into CLAUDE.md's **Conventions** section as well, because
otherwise the next session regenerates exactly what this commit removes: documentation is
present-tense, constraints stay and history moves to the commit message, and a fact lives in
one place with the others referencing it by name.

Fixed in passing, both stale rather than merely historical: the cache-address comment in
both workflows pointed at section 3 of RUNNER-REGISTRY-CACHE.md for a compose file that is
now section 1.

Green after the rewrite: PowerShellInstallerJavaTest, WebServiceContextTest and
WebPersistenceIT (15 tests), and both container IT classes under GRINDER_DOCKER_IT=1
(12 tests). All four workflows still parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
First batch of the repository-wide sweep: `.forgejo/workflows`, `.github/workflows`,
`buildSrc` and the root build script.

Removed throughout -- forge-migration archaeology ("replacing GitLab's `Build Release`",
"ported from .github/workflows/devbuild.yml", "what the old GitLab `pages` job published"),
run and job numbers (133, 472, 536, 644/936, 817, 832), dates on measurements, and
commentary on earlier versions of the comment itself ("the previous wording had the premise
right and the conclusion inverted", "an audit caught the first version listing the
configurations a second time").

Kept, because a reader acts on it: that act exec's `node` inside the job container and so
cannot take a tool image; that a `-v "$PWD:..."` bind under docker-in-docker resolves on the
sibling daemon and silently mounts an empty directory; that Forgejo's `/actions/runs/{run}`
means the per-repo index everywhere except the artifact route, which wants the id; that
MAX_ARG_STRLEN caps one argv entry at 131,072 bytes, so a release body goes in a file; that
an absent Gradle property stringifies to "null", which is not blank.

Measurements kept their numbers and lost their dates, per Griefed: the AppImage glob still
shows what two architectures produce and why a bare `*.AppImage` counts three, the Dokka
HTML output is still ~1411 files / 20 MB.

Two stale references fixed in passing, both naming infrastructure that is gone:
`publishing-conventions` justified itself against `.gitlab-ci.yml` (now
`.forgejo/workflows/release-build.yml`), and `docs.yml` explained a `--runner gitlab` flag it
no longer passes.

`./gradlew help` succeeds, every workflow still parses, and the comment-only check reports
no code changed in any Kotlin file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second batch of the sweep. 24 passages rewritten across `-app`, `-plugin-grinder`,
`-plugin-servertest` and `-plugin-example`.

The same classes of removal as the first batch: "it used to divide by 1048576", "an audit
found the runner untested", "the headless-browser workaround ... was removed on 2026-09-02",
"Decided 2026-07-31", "this guard was green the day it was written -- recorded because an
audit flagged the opposite and was wrong", and two copies of "that used to be reported as
'Error generating Server Pack'".

Where the history was the *argument* for the current shape, the argument is now made
directly. `FileSystemStorageServiceTest` said the code "used to divide by 1048576 and
truncate to an Int"; it now says bytes are required because both SPA tables hide the
download button at size 0 and the 5000MB limit overflows an Int -- the same two facts,
stated as constraints on the code that exists. `ModPackDuplicateCheckTest` likewise argues
from `ModPack.serverPacks` being an eager `@DBRef` rather than from what the method once
did.

`GlobalSettings.changeUpdateSettingState` is the one worth reading twice: its comment
described at length a guard it no longer has, which left the current two-line method looking
under-explained. It now says what is true -- both settings may be on, `ServerPackHandler`
settles the precedence, and disabling one here would hide a setting rather than fix
anything.

Not touched, having been checked: 26 further matches in these modules are present tense
already -- "a configuration that no longer exists", "@param old The old version numbers",
"the previous one was cancelled", "not just the first one registered". A detector cannot
tell those from history, which is why every match was read rather than rewritten on sight.

Reflowed one 230-column single-line KDoc in `RunConfigurationListMigrationRunner` while
removing its historical half.

`:serverpackcreator-app:compileTestKotlin` and both plugin modules compile; the comment-only
check reports no code changed across 35 Kotlin files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third batch. 69 passages across `-api`'s main and test sources.

Removals follow the same rule as the earlier batches. Run and job numbers (472, 646, 1650),
dated measurements ("Measured 2026-07-30: 517 verdicts over 3.5 h, zero HIGH", "Reproduced
2026-09-08: 53 wrappers, 268 log lines, OutOfMemoryError"), and sentences whose subject is a
previous version of the code rather than the code ("It used to ask for `banner.txt`", "the
first draft of this guard failed against correct code", "**And so does NeoForge, which used
to throw instead.**").

Nearly all of these were arguing something true, so the argument is kept and restated about
the code that exists. `JarUtilitiesTest` no longer says the test "used to assert only that
the destination isFile"; it says why the assertion is on content — `copyFileFromJar` creates
the destination before it resolves the stream, so `isFile` is true even when nothing was
copied. `ScriptTemplateContentTest`'s exit-status guard no longer says the loop "used to end
in an unconditional `exit 0`"; it says the loop must propagate the server's status, and what
`BootLogClassifier` does with a `0` exit that has no ready-line. Same shape throughout.

Measurements kept their numbers where the number is the point and lost their dates: the
~80 ms central-directory parse, the 4.71 ms vs 0.14 ms nested-find, the ~392 ms manifest
refresh, Forge 1.20.2 dying at `SecureModuleClassLoader.<init>` while 1.21.1 reaches
`Done (6.593s)!`.

Two backlog identifiers went with them -- B30 and B31 named in doc comments. The tracker is
`claude-docs/BACKLOG.md`; a doc comment that cites a ticket ages into a dangling reference.

Not touched: 8 further matches in this module are present tense -- "@param old The old
version", "files which no longer exist are skipped", "if the manifest has since been
replaced", "the first attempt fails".

A defect in my own verification turned up here and is worth recording, because it would have
let a real change through unnoticed in the other direction: the comment-stripper used to end
a Kotlin raw string at the FIRST `"""`, so `"""if test -s "x.jar""""` -- four consecutive
quotes, which Kotlin closes with the last three -- left it parsing code as string and
comments as code. Fixed to close on the last three of any run; re-verified. The whole branch
now checks clean: 69 Kotlin files differ from `develop` in comments only.

`:serverpackcreator-api:compileTestKotlin` succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth batch. 87 passages across the grinder daemon's main and test sources, the densest
module for this because almost every comment in it was written from a live incident.

The pattern was consistent enough to state once: a sentence of the form "measured on DATE,
X happened, so the code now does Y" becomes "the code does Y, because X" — the mechanism
kept, the incident dropped. `LoaderCacheFailureCooldownTest` is representative: NeoForge
`21.1.247` is still named, because a build listed in version metadata whose installer 404s
is exactly the shape the cooldown exists for, but it is now the shape to picture rather than
something observed on a particular morning.

Numbers were kept wherever the number carries the argument and lost their dates: 98 GB
across 1750 attempt directories, 18–25/74–83/787–1050 ms per `record()` by store size,
Quilt's 306 loader builds against Fabric's 253, `jei` at 602 M downloads against
`fabric-api` at 218 M, 251 ms to select over 38,258 verdicts, 19 of 153 verdicts lost to a
suspending laptop.

Four backlog identifiers removed from doc comments (B35 x4). Those belong in
`claude-docs/BACKLOG.md`, which is where someone can still find out what they meant.

Several comments were arguing from a defect that no longer exists in a way that made the
current code look arbitrary, and those read better now: `FilenameColumnTest` explains why
the column holds a filename rather than why it once held a stem; `VerdictPublicationTest`
explains why the guard iterates `Verdict.entries` rather than which two verdicts a fixed
list of assertions once missed; `GrindPoolOrderTest` argues that the two platforms' download
counts are not comparable rather than that a global sort "was wrong".

33 further matches in this module are present tense and were left: "a sort naming a column
that no longer exists", "not just the first one found", "the previous list", "the first
attempt fails and puts that build on cooldown".

`:serverpackcreator-grinder:compileTestKotlin` succeeds; comment-only check clean across 58
Kotlin files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Final module, and the largest: 203 passages across 73 files. The clientside engine's
comments are almost entirely derived from live grinder output, so nearly every one carried
a date.

Most of it was the case Griefed named directly -- keep the fact, drop the date. 72 lines
needed nothing but the date token removed ("Measured against the live Modrinth API on
2026-09-09, six published `ERROR` verdicts named a dependency that exists one patch away"),
and the numbers that carry those arguments all survive: 36 of 112 kept boot logs failing on
unmet dependencies, 15 of 200 crash logs dying in the sandbox, 20 of 35 Fabric API stagings
on the wrong Minecraft, 16 of 16 Quilt boots running a loader build other than the one
requested, all 90 boots against one poisoned install layer, 210 dependencies dropped by
strict loader matching.

The rest were sentences whose subject was an earlier version of the engine, and those are
restated about the engine that exists. `BootLogClassifier`'s rungs are the clearest case:
each one said what a console "was scored as" before that rung existed, and now says what the
console means and what classifying it wrongly would cost. `ModIdRegistry` no longer explains
that CurseForge "got no guess until" a date; it explains that a guess is only safe because
the refusal split keys on the mapping type.

Three repository landmarks went with the dates, deliberately: CI run 629 in the two
host-process liveness tests, and the `B6`/`A-1`/`A-2` audit identifiers. The run is in
`.claude/rules/ci-workflows.md`; the audit items are in `claude-docs/ANALYSIS-AUDIT.md`.

Also removed three "an audit found/flagged this" asides. They describe how a defect was
noticed, which changes nothing for a reader, and they age into a claim nobody can check.

7 matches remain in this module and are present tense: "the first rule to match decides",
"no longer the protection in this dimension", "the first attempt".

**A note on the verification, because it changed mid-sweep.** The comment-stripping check
used in the earlier batches cannot parse a Kotlin raw string containing a template
expression that itself contains a raw string (`${if (x) """a""" else """b"""}`), and
reported a false positive on `MetadataScannerTest`. It is replaced by a check that needs no
parsing at all and is strictly easier to audit: every line this branch adds or removes must
be a comment line. Run over the whole branch -- 224 Kotlin and Gradle files, 887 insertions,
1001 deletions -- **not one changed line is code**.

`:serverpackcreator-clientside:compileTestKotlin` succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forgejo run 856 (grinder-container-it on the 9.1.0 release commit) failed one test:

  DockerJavaContainerEngineIT > capturesConsoleAndNonZeroExitFromARealContainer() FAILED
  org.opentest4j.AssertionFailedError: expected: <false> but was: <true>

The container runs `echo; echo; exit 3` and exits in milliseconds. The assertion that
failed is `assertFalse(output.timedOut)` -- and the one above it, `assertEquals(3,
output.exitCode)`, passed. So the engine read the container's exit status back correctly
and reported that it had timed out anyway.

That is not a slow host producing a flake; it is `DockerJavaContainerEngine.run` asking the
wrong question:

    while (isRunning(containerId) && !ready.get() && deadline.hasTimeLeft()) { ... }
    val timedOut = !ready.get() && !deadline.hasTimeLeft()

The loop ends for three different reasons and the line after it can only see one of them.
`hasTimeLeft()` is a wall-clock reading taken after create, start and log-attach have all
been paid for, so on a host where those calls take longer than the budget, a container that
finished long ago is reported as having been given up on. `ServerRunner.run` in -clientside
computes the identical expression and has the identical hole.

`ContainerRunOutput.timedOut` already documents the contract the code does not implement:
"Whether the boot ran out of its budget **rather than finishing**".

What it costs is not a mislabelled field. `BootLogClassifier.classify` branches on the
timeout second, immediately after the ready line and before every evidence rung it has, and
returns INCONCLUSIVE -- so a boot wrongly called a timeout throws away its exit code, its
crash, and the client-only class marker the whole engine exists to find. On a loaded host
that silently converts decisive verdicts into "nothing was learned".

This commit lands the seam and the guard, red. `BootDeadline.timedOut` is **stubbed** to
`!ready`, which is close enough to today's behaviour to make the red the defect rather than
a missing symbol, and the production call sites are untouched -- nothing changes behaviour
here. Observed:

  aRunThatFinishedIsNotATimeout()        FAILED  <- the defect
  aRunStillGoingWhenTheWaitEndedIsATimeout()  PASSED  <- vacuous against the stub
  aReadyRunIsNeverATimeout()             PASSED  <- vacuous against the stub

Naming the two vacuous ones explicitly, per this repository's rule about landing a seam with
its guard: they assert nothing the stub could fail, and only become pins in the next commit.

Pinned on the decision rather than through a runner because neither runner can answer it
deterministically -- the container engine needs a live daemon, the host runner starts its own
process, and the case that matters needs the clock of a running boot under test control.
Same reasoning, same module, as `SuspendAwareDeadline.isSuspendGap` and the shutdown-wait
budget.

Also in this commit, both test-only: the failing assertion gets the message it never had (it
reported `expected: <false> but was: <true>` and nothing else), and its 30s budget becomes
2 minutes, since the budget is not that test's subject and the daemon calls around it are
what spend it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the preceding commit's guard green by asking the state the wait ended in rather than
the clock:

    var stillRunning = isRunning(containerId)
    while (stillRunning && !ready.get() && deadline.hasTimeLeft()) {
        Thread.sleep(POLL_INTERVAL_MILLIS)
        deadline.tick()
        stillRunning = isRunning(containerId)
    }
    val timedOut = BootDeadline.timedOut(ready = ready.get(), stillRunning = stillRunning)

A wait ends for three reasons and only one of them is a timeout. `hasTimeLeft()` cannot
distinguish the other two, and it is read after create, start and log-attach have been paid
for -- so on a host where those outlast the budget, a container that exited on its own was
reported as given up on. `stillRunning` is the loop's own last answer, reused rather than
asked again: the daemon is already round-tripped once per poll, and a second inspect would
be a different moment in time.

**Both copies, because there were two.** `HostProcessServerRunner.run` computed the
identical expression for the `-verifyclientside` verb and the servertest plugin's boots.
Fixing only the one that failed CI would have left the other to be rediscovered, which is
the drift shape this module's own context file opens with. The predicate now has one home
beside `SuspendAwareDeadline`, which both already share for the same reason.

In `ServerRunner` the value is read **before** the teardown below it, which kills the
process -- reading after would make every run look finished and the field could never be
true.

What changes for a verdict: a boot whose budget elapsed while it was still running is
unaffected, which is every real timeout. A boot that crashed or exited before the loop
noticed now keeps its exit code and its console instead of being flattened to
INCONCLUSIVE/TIMED_OUT by the second branch of `BootLogClassifier.classify`. That is a
behaviour change on the published API's `ContainerRunOutput.timedOut` and
`RunResult.Completed.timedOut`, and it moves them *onto* their documented contract -- "ran
out of its budget rather than finishing" -- rather than away from it.

Green: BootDeadlineTest 3/3 (the two vacuous guards from the previous commit are now pins),
:serverpackcreator-clientside:test and :serverpackcreator-grinder:test, and all 12 container
ITs under GRINDER_DOCKER_IT=1 including the one that failed run 856.

Landmine recorded in serverpackcreator-clientside/CLAUDE.md beside the suspend-aware one,
since the two are the same mistake made about the same loop from opposite directions: that
one counts time the host was asleep, this one reads a clock where it should read a state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record the timedOut contract change, and widen this file's scope to say so
Some checks failed
Continuous / Build JAR (push) Successful in 18m34s
Documentation / Writerside webhelp (push) Successful in 2m44s
Documentation / Dokka HTML (push) Successful in 6m28s
Docker Test / build image (push) Successful in 27m5s
Qodana / scan (push) Successful in 19m29s
Grinder Container IT / containers (push) Successful in 21m2s
Test / build (push) Successful in 1h28m16s
Continuous / Build AppImage (x86_64) (push) Successful in 3m37s
Continuous / Build AppImage (aarch64) (push) Successful in 3m16s
Continuous / Build Install4J Media (push) Successful in 10m44s
Qodana / notify (push) Successful in 1m1s
Documentation / Help image (push) Successful in 14m42s
Continuous / Continuous Pre-Release (push) Failing after 4m21s
8cce5007ec
Appends the row for `ServerRunner.RunResult.Completed.timedOut` and
`ContainerRunOutput.timedOut` now answering "was this run given up on?" rather than "had
the clock run out?".

**The row required widening the file, which is a judgment call worth stating rather than
burying.** Its header scopes it to "what an exported `serverpackcreator-api` call does",
and neither of these types is in `-api`: `-clientside` and `-grinder` are unpublished, so
the compatibility policy does not bind them and no Maven consumer is affected. Recorded
anyway, for one reason: `ServerRunner` is a `fun interface` a host supplies its own
implementation of and `BootVerifier` takes as a constructor parameter, which is the shape
of an extension point whatever artifact it ships in — and a reader asking "did the meaning
of a result I consume change?" should not have to know which module it came from. The
header now says so in one paragraph, and rows for an unpublished module lead with that fact
so nobody reads this one as a Maven obligation.

Correcting something I said when I offered this row: I claimed `timedOut` is read by
`-plugin-servertest`. It is not. That module depends on `-api` **only, deliberately**, and
references neither `RunResult` nor `timedOut` — it carries its own liveness code for exactly
that reason, which this repository already documents. The real consumers are `BootVerifier`,
the grinder's `ContainerServerRunner`, and any host supplying a custom runner.

Fixed in passing, and unrelated to this change: the `log4j2.xml` row carried
`if (!log4jXml.isFile || devBuild || preRelease)` with unescaped pipes. A `|` inside a code
span still splits a GitHub-flavoured table cell, so that row has been rendering with two
phantom columns. Escaped as `\|\|`, matching the `ApiPlugins.getAllExtensionsOfPlugin` row,
which already escapes its `Grinder \| Tetris` example. Checked the whole file afterwards:
all 61 rows now render as exactly two cells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking "Kotlin API (Dokka)" on https://griefed.github.io/ServerPackCreator/ dies with

  GET .../api/api-object-digest.json 404
  GET .../api/config.json            404
  GET .../api/HelpTOC.json           404
  Error: TOC data error

**Dokka is not the problem and is deployed correctly.** `/api/index.html` on the live site
is byte-identical to a local `dokkaGenerateHtml` (38,250 B), `/api/navigation.html` and
`/api/styles/main.css` serve, and that page references Writerside zero times. Loading the
URL directly works. Only the click is broken.

The published help is a single-page app, and its viewer decides whether a link is a real
anchor or a client-side route with one test, read out of the shipped bundle:

  u = s || /^(?:[a-z]+:)?\/\//i.test(r) ? D_ : P_      // isExternal || scheme-qualified

`api/index.html` is neither, so nothing navigates: the app pushes the URL to `/api/` and
re-bootstraps there, asking for `config.json`, `HelpTOC.json` and `api-object-digest.json`
relative to it. Those are Writerside's own files and exist only at the site root. All three
404, and the TOC dies.

Three things were checked before changing it, because the comment this replaces argued the
opposite conclusion carefully and deserved a careful answer:

  - A root-absolute `/ServerPackCreator/api/index.html` fails the same regex. One leading
    slash is not two, so it would route exactly as before.
  - There is no author-settable escape. `data-external="true"` is emitted BY the builder,
    derived from the href already being absolute -- JetBrains' own pages show it beside
    `rel="noopener noreferrer" target="_blank"` on every https link -- and `topic.v2.xsd`
    contains neither `data-external` nor `data-bypass`. The schema's own words are "To add
    an external hyperlink, specify the full target URL in the href attribute".
  - `product-profile.dtd` offers `toc-element` no attribute for it either.

So absolute is not a preference here, it is the only shape that navigates.

**This was never Pages-specific.** The same href routes the same way under nginx at `/`, so
the container's copy has been equally unclickable. The old comment reasoned only about path
*resolution*, where relative genuinely is right for two roots, and concluded from that alone
that the link worked.

What it costs, stated in the file: this one link leaves a self-hosted container for the
public site. Both layouts still serve `/api/` for anyone addressing it directly, and the
reference also ships as a release asset.

Corrected with it, all four of them stale the moment the href changed: the "single relative
link works in both" claims in `pages.yml`, `docs.yml`, `docker/Dockerfile.help` and
`.claude/rules/ci-workflows.md`.

NOT VERIFIED BY A BUILD. The Writerside builder is an amd64 Docker image and this machine's
Docker is capped at 2 GB, where it OOMs; raising that bounces the host's DNS, so I have not.
The evidence above is read from the shipped viewer, the published schemas and the live
sites. The next `pages.yml` run is the proof.

`spch.tree` validates against product-profile.dtd (33 toc-elements, 32 topics + this one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
Every `[Clientside-mod Addition Request]` issue now gets a real server booted against the mod and its
dependencies the moment it is opened, instead of only when a maintainer applies the `verify-boot`
label. The verification comment links the workflow run, the boot logs and the reporter's own "I told
the mod developer" link, so the verdict can be checked rather than taken on trust.

Why it was worth doing: the form asks reporters to confirm they informed the mod's developer, and they
tick it without having done so. No wording fixes that -- evidence we produce ourselves does, and the
engine for it already existed.

What else changed, all of it visible to someone filing a request:

- The form could be submitted **untouched**. Both link fields carried a `value:` equal to their own
  description, which is pre-filled content, and `required: true` is satisfied by whatever is in the
  box -- so the project link and the proof link could both be prose nobody typed. The defaults are
  gone; the placeholders already said the right thing.
- The proof field reused the project link's `id`, so it was not addressable. It is `informed_proof`.
- Boot logs are now uploaded in full. `BootArtifacts.collect` had no caller outside the grinder, so
  the only file reaching disk was a single `boot.log` that every re-check overwrote -- meaning the
  evidence for a contested crash was gone before anything could upload it. Each attempt's console,
  server logs and crash reports are kept separately now.

`edited` deliberately does not re-trigger the boot: it costs 90 minutes of CI and would cancel the run
that was about to answer the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`21` stood as six separate literals across five build files with nothing linking them -- the Gradle
toolchain, the Kotlin jvmTarget, Dokka's JDK, buildSrc's own toolchain and target, and the IDEA
language level. `kotlin-conventions` even carried a comment saying the target "is not repeated here"
directly above the line that repeated it.

It is now the `java` entry in `gradle/libs.versions.toml`, and nothing else carries a version.

`misc/Java-References.md` is the other half: every Java version reference Gradle **cannot** reach,
with the order to change them in. Twelve CI `setup-java` steps, Qodana's linter, install4j's bundled
JRE, two Dockerfile stages, the AppImage script, the grinder's deploy prerequisites and the prose in
three root documents -- plus the section that matters most, naming what must NOT move with it: the
server-pack script templates track Mojang's per-Minecraft requirement, which is a different number
that happens to share values.

Measured by changing that one entry and nothing else, which is also the useful part for whoever does
the next LTS bump:

  java = "21"   builds, 135 tasks
  java = "17"   fails -- buildSrc's own plugin dependencies require 21, so downwards is blocked
  java = "25"   fails -- "Dependency requires at least JVM runtime version 25. This build uses a
                Java 21 JVM."

That last one is the real prerequisite and was written down nowhere: the Gradle daemon loads buildSrc's
output, so the CI runner JDKs have to move **before** the catalog entry, not after.

One live bug found while taking the inventory and fixed here rather than filed: Dependabot watched the
`docker` ecosystem at `/`, where this repository has never had a Dockerfile. Every image pin in it,
`eclipse-temurin:21-jdk-jammy` included, was watched by nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three field reports, all of them about the Linux AppImages.

**They can update themselves now.** Each release AppImage carries update information and ships a
`.zsync` beside it, so `AppImageUpdate` transfers only the blocks that changed rather than all
~256 MB. Release builds follow stable releases; the rolling dev build follows itself. Note that an
update rewrites the file **in place**, so the name on disk still says the old version afterwards --
read the version from the application, not the filename.

**The bundled Java is pinned.** It was fetched as "whatever Adoptium calls latest", which quietly
decided which systems the AppImage runs on, on somebody else's release schedule.

**A broken bundled Java is now an error.** The launcher used to fall back to the system's Java with a
warning printed to a stdout that a desktop launch has nowhere to show -- which is almost certainly how
"the AppImage is not self-contained" came to be reported in the first place. It now stops and prints
what the bundled Java actually said, so a report carries the cause. `SPC_ALLOW_SYSTEM_JAVA=1` restores
the old behaviour for anyone who needs it.

On the "not self-contained, references glibc 2.15" report: accurate, and it is the bundled JDK's
floor rather than a packaging mistake. Measured across every binary in it -- glibc 2.15 is a March
2012 floor, so every glibc distribution still in use satisfies it. The build now asserts that floor so
a future JDK bump cannot move it unnoticed, and `misc/AppImage-Portability.md` records what the
artifact requires, how an install updates, and why shipping our own glibc was weighed and declined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ServerPackCreator on Windows meant running an installer. It now also ships as a zip you extract and
run -- the Windows counterpart of the AppImage, carrying its own Java runtime, with the same three
entry points the installer gives you: the GUI, the CLI and the web service. Built for x86_64 and
x86_64, marked `_experimental` like the AppImages. The installer is unchanged and is not going away.

Built with `jpackage`, which cannot cross-compile -- JEP 392 makes that an explicit non-goal -- so
unlike the AppImages, which are cross-packaged from one Linux runner because nothing
architecture-specific has to execute, these need real Windows machines to build on.

**That makes this half of a change.** The jobs are here; the runners they need are host configuration
and are not. Until they exist the jobs queue rather than fail, which stalls the dev build's continuous
release and a tagged release -- deliberately, because a release quietly short an artifact is
worse than one that stops. `claude-docs/WINDOWS-RUNNER.md` is the setup, and `misc/windows-runner/`
has a docker-compose that runs the Windows guest as a container on a Linux host, with the
provisioning that registers it.

Three things found while writing that, each of which changes what the setup takes:

- **Forgejo publishes no runner binary for Windows at all** -- every asset of the last five releases
  is Linux. It is Go, so one cross-compile produces it.
- **x86_64 only, and that is a decision rather than a gap.** A Windows ARM64 artifact would need a
  native ARM64 Windows runner, and the only way to host one here is an emulated guest: KVM
  virtualises, it does not translate instruction sets, so qemu falls back to TCG and every guest
  instruction is translated in software. That is a difference in kind rather than degree, and not
  something to gate a release on. Windows on ARM64 runs x86_64 binaries, so that audience is served
  by the one artifact.
- **`winget` cannot install anything from the provisioning context.** It ships inside the App
  Installer MSIX, which is registered per *user*, and OEM provisioning runs as SYSTEM before any user
  profile exists -- so it fails with `The term 'winget' is not recognized`, which reads like a PATH
  problem and is not one. The official installers are downloaded and run directly instead: Node from
  `nodejs.org/dist` and Git for Windows from its GitHub release. Both versions are
  resolved at run time and both selection rules were executed against the live indexes: Node's skips
  29 non-LTS releases to reach v24.21.0, and each Git pattern matches exactly one asset with
  `PortableGit-*` correctly excluded.

The guests are stripped and tuned by `oem/optimise.ps1` before provisioning: OneDrive, Copilot,
Feedback Hub, Clipchamp, News, Teams, To Do, Outlook, Power Automate, Quick Assist, Solitaire, Sound
Recorder, Sticky Notes, Weather, Media Player, Paint and the Xbox stack, plus the policies that stop
Windows re-acquiring them, Search indexing and SysMain off, hibernation off, and Defender exclusions
for the build directories -- which Tamper Protection does not cover on a machine that is not
Intune-enrolled, so they take effect, and the script reads them back rather than assuming it.

Apps are matched by PATTERN and every pattern reports what it matched, because Microsoft renames
these: the Xbox app became `Microsoft.GamingApp`, Teams became `MSTeams`. A hardcoded list of exact
names fails silently -- removes nothing, says nothing -- so a pattern matching zero packages is logged
as such. Verified against a 35-package fixture: every target matched exactly one pattern, and no
pattern reached `VCLibs`, `UI.Xaml`, `NET.Native`, `DesktopAppInstaller`, `WindowsStore` or
`SecHealthUI`, which are protected by name as well.

Nothing in that script is fatal and it runs from its own log, because a fatter or slower guest is
still a working runner and must not lose the step that makes it one.

**Both guests install Windows 11 Pro (`VERSION: "11"`), and the leaner editions are a trap.** LTSC
was the obvious choice and was taken first: it ships without the Microsoft Store, Game Bar, Feedback
Hub and the rest of the consumer set, at 4.7 GB against Pro's 7.9 GB on x64 and 7.5 on ARM, where
every gigabyte is installed under emulation. It is also **Evaluation media, and expires after 90 days
with no product key able to activate it** -- the edition is not licensable, so the deadline is not
something you can buy your way out of.

Every lean alias dockur offers is the same story, which is visible in its source rather than inferred:
`define.sh` rewrites `11l`, `11e` and `11i` to ids ending `-eval` (`win11x64-enterprise-ltsc-eval` and
so on), and `mido.sh` routes any such id through `downloadWindowsEval` to
`microsoft.com/en-us/evalcenter/download-...`. Only `11` takes the other branch, `downloadWindows` to
`microsoft.com/en-us/software-download/windows11`, which is retail media. On ARM it is worse again:
`downloadWindowsEval` redirects an LTSC request on some platforms to the IoT Enterprise LTSC image, so
the guest would not even be the edition the alias names.

Retail media left unactivated does not expire -- a desktop watermark and a locked *Settings >
Personalization*, no shutdowns and no deadline, neither of which reaches a headless build runner. So
`optimise.ps1` is the first line of defence rather than the second, which is what it was written for.
Two escape hatches are in the compose file rather than left to be discovered: `KEY` takes a
25-character product key, which dockur installs with `slmgr /ipk` during setup, and a volume-licensed
ISO bound at `/custom.iso` bypasses `VERSION` entirely -- the only route to a non-evaluation LTSC
guest.

`distribution: 'zulu'` across the new jobs is consistency with every other `setup-java` here rather
than a constraint -- Adoptium, Zulu and Microsoft all publish windows/x64 JDK 21 and 25 GA. It still
decides what ships: jpackage embeds whichever JDK it runs on into the artifact. The `setup-java` count
in `misc/Java-References.md` and the root `CLAUDE.md` moves from twelve to fourteen with these jobs.

Two things the compose gets right that are easy to get wrong, both stated in the file:

- The web viewer and RDP are bound to **127.0.0.1**, not `0.0.0.0`. Port 8006 is an unauthenticated
  console to a Windows VM, before it has a login screen and after; published on a remote host that
  hands a desktop session to anything that can route to the box. The README has the SSH tunnel.
- `./storage-<arch>` is a bind mount holding the installed disk image, so an image update, a host
  reboot or a `down && up` recreates only the container and keeps the registered runner. Commenting
  that line out is how you deliberately start over.
- The installation media -- 7.9 GB -- is downloaded **again after every reinstall**,
  because dockur defaults `REMOVE` to `Y` and deletes the ISO once the install finishes -- so there is
  nothing left to reuse even within one guest. `REMOVE: "N"` keeps it, and a commented
  `./iso/<file>:/custom.iso` line makes it shareable: a bound custom ISO is used in place of any
  download and `VERSION` is ignored entirely, so clearing `storage-<arch>` then costs no bandwidth and
  a second guest on the same architecture reuses the same file. It ships commented rather than as
  something to delete later because Docker creates a DIRECTORY at a missing bind source, and a
  directory at `/custom.iso` fails confusingly rather than clearly.

Four defects reported from real runs and fixed here:

- **Node and Git resolved to garbage under Windows PowerShell 5.1.** The pipeline form
  (`Invoke-RestMethod ... | Where-Object { $_.lts } | Select-Object -First 1`) resolves correctly under
  PowerShell 7 and returned EVERY version at once under 5.1, which is what the guest runs: `$lts`
  became an 868-element string, the URL built from it was nonsense, and it surfaced as `400 Bad
  Request` from nodejs.org. Both resolutions are `foreach` now, which behaves the same under either
  edition, and both validate the shape of what they resolved -- so a bad resolution fails saying so
  instead of building a URL out of it. **The rules were verified in pwsh 7 and that could not have
  caught this**; the container is a different PowerShell from the target.
- **The runner's DEPRECATION WARNING killed provisioning.** `register` writes it to stderr, and
  `... 2>&1 | Tee-Object` turns every stderr line into an ErrorRecord; with
  `$ErrorActionPreference = 'Stop'` a NativeCommandError is terminating, so a warning that is not a
  failure stopped the script -- at the point where registration had very likely already succeeded.
  It now runs through `Start-Process` with process-level redirection, which never reaches PowerShell's
  stream system, so only the exit code decides anything, and both streams are still captured and
  printed. A `.runner` existence check follows it: exit 0 is the claim, the file is the evidence, and
  without it the service would start, find no configuration and idle for ever -- a runner that
  registered and never answers.
- **Registration is a configuration file, not a command.** Forgejo's web UI hands out a UUID and a
  Token from "Create new runner" and its own documentation says to copy them into the runner
  configuration's `server` section -- which is what `forgejo-runner register`'s deprecation warning
  means by "declare connections in the runner configuration instead". `provision.ps1` writes that
  `server.connections` block from `runner-uuid.txt` and `runner-token.txt` and does nothing else. The
  `register` route is gone rather than kept as an alternative: it consumes a separate single-use
  registration token for no gain, and supporting both meant sniffing the credential's shape to guess
  which one the operator meant.
  The token's format is deliberately NOT asserted. Its length was hard-coded here twice and wrong
  twice -- 43 characters read off the development branch, then 40-hex-versus-43 as a shape to route
  on -- so the check now catches an empty file, a leftover placeholder and pasted prose, reports the
  length and a masked form of what it read, and lets the instance judge the rest. The UUID is checked
  as a UUID, a format that does not move.
- **A backtick inside a double-quoted here-string ate a letter.** `` `r `` is a carriage return, so
  "`registration token not found" rendered as "egistration token not found" in the one message whose
  job was to be read carefully. No backticks in those strings, and a note saying why.
- **`COMMAND` and `oem/install.bat` are ONE mechanism, and setting both made dockur's linter report
  a critical error on every boot.** dockur does not run `COMMAND` separately: `install.sh` appends it
  to the install.bat it finds in /oem (`printf '%s\n' "$COMMAND" >> "$file"`, after a blank line), so
  the diagnostic breadcrumb that was set here landed on line 22, after that file's `exit /b 0` on line
  20, where it could never execute -- "Unreachable code after EXIT or GOTO (E008)". `COMMAND` is gone
  from both services, with the reason in the file so it is not re-added.
- **Binding a custom ISO onto an installed guest REINSTALLS it**, which the ISO instructions did not
  say. `needsInstall` compares the recorded media against the current media and a path change counts;
  the only carve-out is REMOVING custom media after a completed install, never adding it. The README
  now gives the order that costs nothing -- bind before first start if you already have an ISO, and
  otherwise switch at the next deliberate rebuild rather than on a working guest.
- **The runner was registered as a Windows SERVICE, which cannot work.** `forgejo-runner daemon` is an
  ordinary long-running console program -- the runner implements no Windows service support anywhere --
  so `sc.exe create` made the Service Control Manager start it, wait for the status report a service is
  required to send, and kill it after about 30 seconds. The runner connects inside that window, which
  Forgejo displays as a runner that is OFFLINE with a "last online" timestamp a few minutes old rather
  than as any kind of failure. It runs as a SCHEDULED TASK at startup now, under SYSTEM, through a
  generated `run-runner.cmd` that fixes PATH -- a scheduled task has no equivalent of a service's
  `Environment` registry value, and PATH is what bit this guest once already -- and keeps a
  `daemon.log`, since a daemon started by the scheduler has nowhere else to write. An existing service
  registration from an earlier run is removed.
  The provisioning now CHECKS the daemon is still alive ten seconds after starting it. The service
  registration it replaces was never checked, which is exactly why a runner the SCM had already killed
  read as a successful provisioning.
- **`disable DoSvc` answered `Access is denied`.** Delivery Optimization's service key is
  ACL-protected and `Set-Service` cannot touch it even as an administrator. It is out of the service
  list, replaced by `DODownloadMode = 0` under the policy key -- HTTP downloads still work, peer-to-peer
  sharing does not happen, and the policy takes priority over the Settings UI.

- **`git not found` immediately after installing git.** An installer writes the machine PATH in the
  registry and cannot update a process that is already running; re-reading the registry turned out to
  be necessary and not sufficient. The script now also asserts the install directories itself and
  calls `git` by resolved full path, so the step cannot depend on what the installer did or when.
- **`registration token not found` reported only as `exited 1`.** That message is about the token
  being stale -- the *Create new runner* page issues a fresh one each time it is opened, invalidating
  the last -- and says nothing about the runner, labels or network. The failure is now recognised and
  explained, and the token file is checked for shape first, so a leftover `<registration-token>`
  placeholder or an empty file fails saying *that* instead of looking like a rejected token. Verified
  across nine real file shapes: newline, CRLF, BOM and surrounding spaces all accepted; two tokens,
  an empty file, the placeholder and a pasted URL all rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every `uses:` in both workflow directories now names a version -- `actions/checkout@v6` -- instead of
a 40-character commit hash with a `# v6.0.3` comment beside it. The trailing comments go with them,
because the reference says what the comment said.

This is not a rename, and the measurement is why it was worth doing: every major tag was already
AHEAD of the commit it replaced, so the SHA pins had quietly frozen eight actions at older releases.

  actions/checkout           v6  d23441a4  <- pin 9f698171
  actions/setup-java         v5  b6effb05  <- pin 1bcf9fb1
  gradle/actions             v6  3f5f9ada  <- pin 3f131e86
  actions/cache              v5  caa29612  <- pin 27d5ce71
  actions/setup-node         v6  24997072  <- pin 20280fbc
  docker/build-push-action   v6  10e90e36  <- pin 26343531
  docker/setup-buildx-action v3  8d2750c6  <- pin e468171a
  docker/setup-qemu-action   v3  c7c53464  <- pin 29109295

What it trades is stated rather than glossed: a tag is mutable, and in `.forgejo/workflows` a bare
`owner/repo` resolves through a mirror, so it trusts that too. Against which: immutability nobody
revisits is staleness, which is what those eight lines are.

Every reference was resolved with `git ls-remote` against the host that workflow actually uses before
being converted. Four cannot take a bare major and are therefore not `@v1`:

  tiyee/action-ssh@v1.0.1                no `v1` tag or branch exists; v1.0.1 is the only tag
  nogsantos/scp-deploy@v1.3.0            tags are v1.0.0..v1.3.0, no v1
  luangong/setup-install4j@v1            `v1` is a BRANCH, not a tag
  jmgilman/actions-generate-checksum@v1  same

The last two would have been converted silently and described as tags by anything trusting the old
`# v1` comments, which named a tag neither repository has.

`.forgejo/workflows/test.yml`'s header argued for SHA pinning in as many words and now states the new
policy and its cost; `.claude/rules/ci-workflows.md` gains the same, so nothing is left making the
case for what this undoes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
commit cd8bb5a0966e8e4c961e6c9d08bebd34534a2d08
Author: Griefed <griefed@griefed.de>
Date:   Mon Oct 5 13:19:14 2026 +0200

    docs(grinder): record that neither the store load nor the reaper's second walk explains a slow restart

    A restart that took "ages" was attributed first to the verdict store and then to the reaper, on
    reasoning rather than measurement. Both were then measured against a live 164 MB / ~215,000-row store
    and 354K inodes of leftover staging, and both are negative results worth keeping so they are not
    chased a third time.

    The store load is ~0,44-0,6 s. `measureLoadCost` puts the tree-then-convert pass at 605,6 ms against a
    streaming `MappingIterator`'s 427,7 ms for 215,000 fully-populated rows, on a fixture that overstates
    the real file by ~39%. The saving is a ratio of ~30%, not a constant, so a full second needs ~1,2M rows.

    Fusing the reaper's two traversals saves ~4%. It was implemented and proven behaviour-identical --
    11 guards green with no assertion changed, and an identical 19.200.000 bytes reclaimed across five
    runs -- then measured at 306.300 inodes: 17.219 ms before (2 runs), 16.510 ms after (3 runs). The
    unlinks dominate and the stat pass is nearly free once the dentry cache is warm. Reverted rather than
    landed, on the same grounds B30 was: a real saving that buys nothing worth the churn.

    What the exercise did leave behind is the scale -- roughly 17 s per 300K inodes of staging on a warm
    local SSD -- and the route to an answer: `Reclaimed N MiB of staging left behind by a previous run.`
    is INFO and is logged after the sweep, so the gap between it and the line before it IS the reaper's
    cost. Reading that beats predicting it, which is the part this entry exists to stop repeating.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 1412e6b0e12430b1a95b5e1375db87b6e625b74f
Author: Griefed <griefed@griefed.de>
Date:   Mon Oct 5 13:12:31 2026 +0200

    test(grinder): pin that a partial delete contributes no bytes to the reclaimed total

    `BootWorkspaceReaper` reports "bytes this sweep is known to have freed", and the rule that makes
    that true is all-or-nothing: `reclaimed += size` runs only when `deleteRecursively()` returned true,
    so an entry that could not be fully removed counts as zero even though some of its files did go. The
    entry is left behind for the next sweep to retry.

    Ten guards covered what survives, what is deleted, the scoping by platform and slug, and an exact
    byte figure -- and not one of them covered that rule. Proven, not assumed: mutating the accounting to

        entry.deleteRecursively()
        reclaimed += size

    leaves all ten green and fails only the guard added here, `expected: <2048>` against the 3072 the
    mutation produces. So the subtlest half of this class was unpinned, and a change that fused the
    sizing and deleting traversals could have started reporting bytes for packs still on disk with
    nothing to catch it.

    Green against unmodified code, which is correct for a characterization pin: it documents behaviour
    that already exists, ahead of restructuring that must preserve it. The red above is what shows it
    has teeth.

    **The failure is manufactured by clearing write permission on a directory, which root ignores.** So
    the precondition is asserted AFTER the sweep, via `Assumptions.assumeTrue` on the file still being
    there -- a run where the trick did not bite skips rather than passes, because a guard that cannot
    run must never report success. Verified it is not silently skipping here: 11 tests, 0 skipped.
    Permissions are restored in a `finally`, or @TempDir cannot clean up and the failure lands on an
    unrelated test.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit d18bbee1409c2a2806155d5651d76c55b628102b
Author: Griefed <griefed@griefed.de>
Date:   Mon Oct 5 13:12:18 2026 +0200

    build(grinder): measure what a restart pays to load the verdict store

    The benchmark could time `record()` and the components of `persist()`, but not `load()` -- so the
    one number a restart actually waits on was the one it could not produce. `ReportServer.start()` is
    the last thing `GrinderApplication` does, after the store's constructor has run, so a load is dead
    time before the report answers at all.

    Adds `measureLoadCost`, timing the tree-then-convert pass `JsonVerdictStore.load` performs against a
    streaming `MappingIterator` over the same bytes. Both keep per-row failure isolation; they differ
    only in whether the whole file is materialised as a tree first.

    Also adds `populatedVerdict`, a fixture filling all 21 fields rather than the seven the write
    measurements use. That matters twice: a real store's rows are bound field by field, so a
    mostly-null fixture under-reports the binding cost, and the bytes/row it produces is what makes the
    file size comparable to a deployed store's.

    Measured on this machine, against a live store of ~215,000 rows / 164 MB:

        [load]   10000 rows   10,5 MiB  tree then convert      23,2 ms
        [load]   10000 rows   10,5 MiB  streaming              19,5 ms
        [load]  100000 rows  105,6 MiB  tree then convert     226,0 ms
        [load]  100000 rows  105,6 MiB  streaming             185,7 ms
        [load]  215000 rows  227,6 MiB  tree then convert     605,6 ms
        [load]  215000 rows  227,6 MiB  streaming             427,7 ms

    The fixture is 1,110 B/row against the live store's ~800, so 227,6 MiB OVERSTATES a 164 MB file by
    about 39%; the real load is nearer 440 ms with ~130 ms available. **That is the finding, and it is a
    negative one:** switching to a streaming load is not worth doing. The saving is a ratio of roughly
    30% rather than a constant, and scaling is near-linear -- 21,5x the rows gives 26x the time -- so a
    full second of saving needs ~1,2M rows, by which point `persist()` rewriting the whole file every
    flush costs far more than the once-per-restart load does.

    Recorded rather than acted on, because the measurement is what stops it being re-argued: the store
    is ~0,5 s of a restart, so a slow start is somewhere else.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: Griefed <griefed@griefed.de>
Audit of the 54 commits since `RELEASE: 9.0.0`, concentrating on the 2026-09-28 .. 2026-10-05 range,
which had never been audited in `develop` form. No HIGH findings. Six findings, all one class -- a
number or a plural that was wrong when written or became wrong when the code moved -- fixed by
amending the two commits that introduced them.

The two that were never true, both in `.claude/rules/ci-workflows.md`: "all 105 occurrences are now
the tag" and "the 78-odd occurrences under `.forgejo/workflows`". Measured: 72 `uses:` lines in all
(`.forgejo` 57, `.github` 15), of which that commit converted 63. The other nine were never SHAs --
seven in the Windows app-image jobs, authored as tags from the start, and two local
`./.github/workflows/...` reusable-workflow references, which take no version at all. So the count
was taken over a population that had already stopped being uniform, and the later ARM64 removal
changed 77 to 72 without touching a converted line. The file now gives the split and the command
that re-derives it.

The other four were stale plurals left by the ARM64 removal: the root `CLAUDE.md` still said the
app-image *jobs* need Windows *runners*, `REFACTOR-LOG.md` repeated the 105 and said "real Windows
runners", and `misc/windows-runner/.gitignore` described "a running pair of guests" while ignoring a
`storage-arm64/` directory no service produces.

Recorded clean, so it is not re-litigated: the `-api` module boundary (no Swing, no Spring web, no
frontend); `d70628aa6` is an honest `refactor:` and changes no test file; the `timedOut` behaviour
change really did get its `API-BEHAVIOUR-CHANGES.md` row via `8cce5007e`; `6d9192905` takes the
sanctioned seam-with-the-guard route and names both vacuous guards with the observed run beside them;
the seven present-tense `docs(...)` commits are machine-verified comment-only, zero non-comment lines
across 58 files in -grinder alone; the AppImage glibc floor is enforced in the script rather than only
documented, so a local build pays it too; and `JavaVersion.kt` reads the catalog rather than shadowing
it.

One thing left alone deliberately: `serverpackcreator-plugin-example/README.md` has ~28 broken
relative links (paths missing their `/kotlin/` segment). No commit in this range touches that file, so
it is pre-existing and wants its own commit rather than being smuggled into an amend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(example-plugin): point the README's source links at the package they actually live in
Some checks failed
Documentation / Writerside webhelp (push) Successful in 5m23s
Documentation / Dokka HTML (push) Successful in 10m38s
Continuous / Build JAR (push) Successful in 16m22s
Docker Test / build image (push) Successful in 18m27s
Grinder Container IT / containers (push) Successful in 13m56s
Continuous / Build Windows app-image (x86_64) (push) Failing after 7h16m42s
Qodana / scan (push) Successful in 17m51s
Continuous / Build AppImage (x86_64) (push) Successful in 2m10s
Continuous / Build AppImage (aarch64) (push) Failing after 1m2s
Documentation / Help image (push) Successful in 7m49s
Qodana / notify (push) Successful in 1m32s
Continuous / Build Install4J Media (push) Successful in 8m30s
Continuous / Continuous Pre-Release (push) Has been skipped
Test / build (push) Failing after 40m22s
2602ec344d
Every link to a source file in this module's README was missing the `kotlin` package segment --
`src/main/kotlin/de/griefed/example/gui/panel/Panel.kt` where the file is at
`src/main/kotlin/de/griefed/example/kotlin/gui/panel/Panel.kt`. Ten links across eight distinct
targets, all of them 404 on the forge and all of them dead in an IDE.

That matters more here than in most READMEs: this module is documentation by example -- the one plugin
that has to reflect current API idiom, because it is what a plugin author reads before writing
anything. A tour whose links do not open the code it is touring is the one defect it cannot afford.

Both copies, because there are two and only one is editable. `shipPluginDocuments` copies the module
root's README into `src/main/resources/` so it ships inside the plugin jar, the same arrangement
-api's `shipRootDocuments` uses -- so an edit to the resources copy is silently undone by the next
build. The root file is the source; the shipped copy here was regenerated by running that task rather
than copied by hand, which also confirms the task still does what its name says.

Verified by resolving every relative link in the file against the filesystem: 0 unresolved from the
module root, where previously 10 were. The remaining relative links -- `build.gradle.kts` and the six
`img/*.png` -- were always correct from the root and are untouched.

Left alone deliberately: those same relative links cannot resolve from inside
`src/main/resources/`, one directory tree deeper, and no edit to the text can make them work in both
places at once. The shipped copy exists to be read, not clicked, so the root is the copy the links are
correct for.

Found by the 2026-10-06 `main..develop` audit and deliberately not folded into the amends it produced:
no commit in that range touches this file, so this is pre-existing breakage and gets its own commit
rather than riding along in an unrelated one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test.yml` run 889 (URL numbering; API id 1222) failed one test:

  KDocAttachmentTest > noKDocBlockIsOrphanedFromItsDeclaration() FAILED
  StoreWriteBenchmark.kt:120 ==> expected: <true> but was: <false>

`measureLoadCost`'s doc block sat immediately above `populatedVerdict`'s doc block, so it attached to no
declaration at all and dokka drops it: the function it documents was left undocumented and the paragraph
explaining why a restart waits on the store was rendered nowhere.

Cause is mechanical and worth naming, because the next edit of this shape will do it again. The commit
that added both of them inserted the new helper by matching the TEXT of the function signature --

    "    fun measureLoadCost(dir: File) {"  ->  <helper> + "    fun measureLoadCost(dir: File) {"

-- which lands the helper between that function and its own KDoc, because the anchor was the `fun` line
and the doc block sits above it. Anchor on the doc block, or on the blank line before it, when inserting
a member ahead of a documented one.

Nothing moves but the comment: the block is lifted out and put back immediately above
`fun measureLoadCost`, byte-identical. No code, no measurement and no fixture changes.

`KDocAttachmentTest` is the guard `test(api): pin that no KDoc block comes loose from its declaration`
added, and this is the first defect it has caught that was not already known -- the structural scan it
replaced found 18 instances where Qodana's `KDocUnresolvedReference` saw 4, and this one would have been
invisible to Qodana too, since the stranded block contains no `[link]` to go unresolved.

Green: `:serverpackcreator-api:test --tests '*KDocAttachmentTest*'` -> 1 test, 0 failures, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`devbuild.yml` run 884 (URL numbering; API id 1217) failed `Build AppImage (aarch64)`:

  ERROR: the bundled JDK needs glibc 2.17, above the 2.15 floor.
  Raised by: .../usr/lib/jdk/bin/java, jdb, javac, jarsigner, jstat, ...

The same run's x86_64 job passed the identical check with `2.15 at most (floor 2.15)`, which is the whole
finding: **the floor was measured on the x86_64 JDK and applied to both.** One `JDK_MAX_GLIBC="2.15"`
governed two different builds of Temurin that do not have, and cannot have, the same floor.

`2.17` on aarch64 is not Temurin being careless and no JDK choice would improve it. glibc 2.17 is the
FIRST release with an AArch64 port at all (2012-12-25), so no aarch64 glibc binary has ever referenced
anything older -- 2.15 is unreachable on that architecture by construction, and the check as written
could never have gone green there.

So the number moves to where the architecture is decided: `JDK_MAX_GLIBC` is now set inside the
`TARGET_ARCH` case, beside `BUILD_ARCH` and `JDK_ARCH`, with the measurement for each build next to it.
That is also what stops the next architecture being added without stating its floor.

The guard itself is unchanged and still has teeth, verified against the comparison directly:
floor 2.15/found 2.15 pass, 2.17/2.17 pass, 2.15/2.17 FAIL (the observed failure), 2.17/2.15 pass,
2.17/2.18 FAIL. So raising the aarch64 floor to 2.17 accepts exactly what that JDK needs and still
rejects anything above it.

`misc/AppImage-Portability.md` gains the second row rather than having the first one edited: both floors
are real and a reader checking "will this run on my system" needs the one for their machine. It also
states that 2.17 is a floor nobody can undercut, so it does not read as a regression against 2.15.

**This is the fourth instance of "a fix written against one artifact is a fix for one artifact"** in this
repository's log. The measurement was correct, the guard was correct, and the scope was assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`devbuild.yml` run 884 (URL numbering; API id 1217) failed `Build Windows app-image (x86_64)` without
running a line of it:

  Cannot find: pwsh in PATH
  skipping post step for 'Upload Windows app-image'; main step was skipped

`shell: pwsh` is PowerShell 7, a SEPARATE install. Windows ships `powershell.exe` -- Windows PowerShell
5.1 -- and nothing else, and `provision.ps1` installs Node and Git but no pwsh. So the step could never
have run on the guest it was written for.

Worth stating plainly because the symptom is misleading: act skips the step, then skips every later step
that depended on it, and reports the JOB as failed. Nothing says "missing interpreter", and the run reads
as the Windows build being broken rather than as never having started. The job's own log is 63 KB of
successful checkout and `setup-java` before one line of truth.

All three sites move to `shell: powershell`, confirmed supported rather than assumed: act maps it in
`pkg/model/workflow.go` as `case "powershell": shellCommand = "powershell -command . '{0}'"`.

`misc/build-winimage.ps1` needed no change -- scanned for 7-only constructs (ternary, `??`, `&&`/`||`
chains, `-Parallel`, `Join-String`, `-AsHashtable`, `$PSStyle`) and has none, and every cmdlet it uses
(`Compress-Archive`, `Get-FileHash`, `Join-Path`, `Resolve-Path`, `Set-Content -NoNewline`, ...) exists
in 5.1. Its header now says which dialect it is written in, so the next edit knows the constraint
instead of rediscovering it.

Two inline `Get-ChildItem` results are wrapped in `@()`. 5.1 unwraps a single-element result to a bare
`FileInfo`, so `$image.Count` and `$image[0]` worked only by the compatibility shims PowerShell 3 added
-- and the devbuild site tested `if (-not $image)` where the release site tested `.Count -ne 1`, which is
two spellings of one question. Both now count, both on a real array. This is the same edition trap that
produced an 868-element `$lts` and a 400 Bad Request from nodejs.org during provisioning; it is written
to be edition-identical by construction rather than tested on the wrong edition again.

`claude-docs/WINDOWS-RUNNER.md` records the constraint beside the node/git/long-paths checklist, where an
operator looks before blaming a workflow, and drops a stale "both hosts" from the Node line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(ci): make the continuous release say why it failed instead of printing a traceback
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m7s
Documentation / Dokka HTML (push) Successful in 4m5s
Continuous / Build JAR (push) Successful in 10m38s
Docker Test / build image (push) Successful in 14m49s
Grinder Container IT / containers (push) Successful in 10m4s
Documentation / Help image (push) Successful in 5m11s
Qodana / scan (push) Successful in 12m53s
Continuous / Build AppImage (x86_64) (push) Successful in 2m18s
Continuous / Build AppImage (aarch64) (push) Successful in 2m55s
Qodana / notify (push) Successful in 10s
Continuous / Build Install4J Media (push) Successful in 8m7s
Test / build (push) Successful in 25m12s
Continuous / Build Windows app-image (x86_64) (push) Failing after 1m11s
Continuous / Continuous Pre-Release (push) Has been skipped
de86ca6a46
`devbuild.yml`'s `Continuous Pre-Release` has been failing since 2026-10-03 (run 866 by URL numbering,
API id 1191) with:

  Deleting previous Forgejo continuous release 1561 (the tag is kept)
  json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

That traceback names neither the HTTP status nor the reason, because the step throws both away:

  ID=$(curl -sf -X POST "$API/releases" ... | python3 -c '...json.load(sys.stdin)["id"]')

`curl -f` prints NOTHING on an HTTP error -- that is what the flag is for -- so python parses an empty
string and reports a syntax error about a response it never received. And the step ran under `set -eu`
WITHOUT `pipefail`, so a pipeline's status is python's: curl's non-zero exit was masked, which is why
the job did not stop at the real failure.

So two blindfolds stacked, and the symptom named the inner one. The POST now captures status and body
separately and fails with both; the DELETE is checked the same way rather than relying on `-f`; and
`pipefail` is on, so a failing curl in any remaining pipeline cannot hide behind the parser downstream.

**The root cause is still unknown and this commit does not claim to fix it.** What the log proves is
that the DELETE reported success and the POST returned an empty body 0,4 s later; whether that is a 409
against a tag Forgejo still considers taken, a permission on `FJ_TOKEN`, or something else is exactly
what the response body would have said and exactly what was discarded. Guessing a fix for an API error
nobody has read would be the wrong order of work -- the next run now prints the answer.

Found while fixing the three jobs that failed on the latest push. This job has not actually RUN since
2026-10-03: it `needs` the jobs that were failing, so it has been skipped rather than red, and the three
fixes beside this one are what will let it reach the API again. Worth knowing before reading the next
run as a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(winimage): the self-containment guard asks for a file jpackage never emits
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m46s
Documentation / Dokka HTML (push) Successful in 4m27s
Continuous / Build JAR (push) Successful in 11m15s
Docker Test / build image (push) Successful in 12m37s
Grinder Container IT / containers (push) Successful in 10m39s
Continuous / Build Windows app-image (x86_64) (push) Successful in 10m57s
Test / build (push) Successful in 11m0s
Qodana / scan (push) Successful in 13m45s
Continuous / Build AppImage (x86_64) (push) Successful in 3m7s
Continuous / Build AppImage (aarch64) (push) Successful in 2m25s
Qodana / notify (push) Successful in 1m13s
Documentation / Help image (push) Successful in 8m10s
Continuous / Build Install4J Media (push) Successful in 6m44s
Continuous / Continuous Pre-Release (push) Successful in 4m41s
95a09edd03
commit bc45900423
Author: Griefed <griefed@griefed.de>
Date:   Tue Oct 6 20:52:26 2026 +0200

    fix(winimage): keep the JDK's debug attributes so a stack trace names a line

    `--strip-debug` drops LineNumberTable and friends from the JDK's own classes, so every JDK frame in
    every stack trace reads `(Unknown Source)`. The other two artifacts do not do this: build-appimage.sh
    downloads Adoptium's `/jdk/` image type and spc.install4j bundles `21/jdk-21.0.4+7`, both complete
    JDKs. A bug report's trace is mostly JDK frames, so the Windows app-image was the one artifact that
    would answer a user's crash with no line numbers.

    Measured, Temurin 21.0.5 (macOS arm64), runtime bytes only:

        jpackage defaults                             157,672,482
        this script's options (neither strip flag)    174,078,223   +16,405,741  (+10.4%)

    of which the launchers are 1,108,928 and the debug attributes the remaining ~15.3 MB.

    Verified against an app that throws from inside java.base, built with the script's exact argument
    set (both `--add-launcher`s plus `--jlink-options`): all ten entries the guard requires are present,
    `man/` and `include/` are still excluded, and the trace reads

        at java.base/java.lang.Integer.parseInt(Integer.java:662)

    where the same app under jpackage's defaults reads

        at java.base/java.lang.Integer.parseInt(Unknown Source)

    `--no-man-pages` and `--no-header-files` stay. The 8 JNI/JVMTI headers they exclude are 211,615 bytes
    of build-time material for compiling native code against a JVM, which the JVM never opens -- parity
    with a complete JDK is about diagnosing a user's problem, not about shipping a compiler's inputs.

    Script parses clean (`[Parser]::ParseFile`).

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 20b7254782
Author: Griefed <griefed@griefed.de>
Date:   Tue Oct 6 20:47:37 2026 +0200

    fix(winimage): keep the bundled runtime's launchers so SPC's own java.home resolves

    jpackage's default jlink options begin with `--strip-native-commands`, which deletes every launcher
    from the bundled runtime. SPC fills its Java-for-modloader-server setting from `java.home\bin\java`
    (`SystemUtilities.acquireJavaPathFromSystem`), and on Windows that method appends `.exe` to any
    drive-letter path without checking it exists -- so under a stripped runtime the setting lands on a
    file that is not there, is stored as such by `JavaConfig.javaPath`, and is what Global Settings
    displays. The AppImage does not have this, because it bundles a complete JDK; nor does the install4j
    build.

    Passing `--jlink-options` REPLACES all four defaults, so the three that are still wanted are named
    explicitly and only `--strip-native-commands` is left out.

    Measured, Temurin 21.0.5 (macOS arm64), same app, one jpackage run per variant. `lib/modules` is
    119,885,790 bytes in every one of them, so each delta is exactly the files its flag controls:

        variant                      runtime bytes    include/   runtime/bin
        jpackage defaults            157,672,482             0             0
        no --strip-native-commands   158,781,410             0     1,108,928
        no --no-header-files         157,884,097       211,615             0
        neither                      158,993,025       211,615     1,108,928

    So the launchers cost 1,108,928 bytes across 22 stubs. `--no-header-files` and `--no-man-pages` stay:
    the 8 headers they exclude are compile-time material for building native code against a JVM and the
    JVM never opens `include/`. `--strip-debug` stays for now at 16.4 MiB (168,792 vs 185,176 KiB), the
    cost being `(Unknown Source)` instead of a line number on JDK frames in stack traces -- SPC's own
    frames are unaffected, since its classes live in `app\` rather than in the jimage.

    Verified on the resulting image, with the script's exact argument combination (both `--add-launcher`s
    plus `--jlink-options`): all three launchers and all three cfgs present, `include/` still absent, the
    GUI and CLI launchers both run, `runtime/bin/java -version` reports 21.0.5, and
    `runtime/bin/java -jar app/Hello.jar` runs the application standalone. The script parses clean
    (`[Parser]::ParseFile`).

    The guard asserts `runtime\bin\java.exe` again. It is a pin on this decision now rather than a claim
    about jpackage: losing the option is a failed build instead of a setting that silently points at
    nothing.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit f7a50442dc
Author: Griefed <griefed@griefed.de>
Date:   Tue Oct 6 20:28:59 2026 +0200

    refactor(winimage): name the staging input directory something that is not an automatic variable

    `$input` is PowerShell's automatic pipeline-input enumerator. Assigning to it at script scope is
    legal and works here only because nothing in this script reads a pipeline -- the moment any of it
    moves into a function or a pipeline stage, `$input` is rebound underneath and `--input` is handed the
    wrong thing, silently. `$inputDir` also says what it holds, which `$input` did not.

    Behaviour-preserving: a rename plus the aligned assignments around it, no change to any value passed
    to jpackage. Parses clean (`[Parser]::ParseFile`, PowerShell 7.4 in a container).

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit ac41456e0c
Author: Griefed <griefed@griefed.de>
Date:   Tue Oct 6 20:28:47 2026 +0200

    fix(winimage): the self-containment guard asks for a file jpackage never emits

    The post-build check required `runtime\bin\java.exe` inside the app-image. jpackage jlinks the
    bundled runtime with `--strip-native-commands` -- the first entry of DEFAULT_JLINK_OPTIONS in
    jdk.jpackage's StandardBundlerParam -- and jlink's StripNativeCommandsPlugin drops every NATIVE_CMD
    entry, so no jpackage app-image on any platform contains a `java` launcher. The guard therefore
    failed every Windows build against a perfectly correct image, after jpackage had already succeeded:

        jpackage reported success but produced no runtime\bin\java.exe -- the image is not self-contained.

    Measured, since there is no test harness for this script:

      - `jpackage --type app-image` on JDK 21.0.5 (Temurin, this machine) produces a runtime with NO
        `bin` directory at all -- `find image -type d -name bin` returns nothing, and the VM sits in
        `lib/server/libjvm.dylib` beside `lib/modules`.
      - A second run with `--add-launcher Hello-CLI=cli.properties` confirms jpackage writes one cfg per
        launcher: `app/Hello.cfg`, `app/Hello-CLI.cfg`, `app/Hello.jar`.
      - `DefaultImageBuilder.nativeDir` routes every `.dll` into `bin` on Windows and everything else
        into `lib`, which is what puts the Windows VM at `runtime\bin\server\jvm.dll` while the jimage
        stays at `runtime\lib\modules`.

    So the guard now checks what self-containment actually consists of: the three launchers, the jar,
    one cfg per launcher, the VM and the module image. Each of those is load-bearing -- a missing cfg
    leaves a launcher unable to find its main class, a missing jimage leaves the VM nothing to run.

    The three `.exe` checks are known to have passed in the failing run, since the throw came from the
    fourth element of the old list.

    `claude-docs/WINDOWS-RUNNER.md` repeated the same wrong claim in its hand-off section and now states
    what the script checks, and why there is no `java.exe` to check for.

    Verified: the script parses clean (`[Parser]::ParseFile`, PowerShell 7.4 in a container), and
    nothing added here is a PowerShell 7-only construct.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: Griefed <griefed@griefed.de>
fix(ci): the sponsors list is emptied by a token scope, not by Forgejo
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m4s
Documentation / Dokka HTML (push) Successful in 4m15s
Continuous / Build JAR (push) Successful in 10m16s
Grinder Container IT / containers (push) Successful in 13m43s
Docker Test / build image (push) Successful in 23m39s
Qodana / scan (push) Successful in 15m24s
Continuous / Build AppImage (x86_64) (push) Successful in 2m58s
Continuous / Build Windows app-image (x86_64) (push) Successful in 18m44s
Test / build (push) Successful in 9m47s
Continuous / Build AppImage (aarch64) (push) Successful in 2m32s
Qodana / notify (push) Successful in 28s
Documentation / Help image (push) Successful in 10m52s
Continuous / Build Install4J Media (push) Successful in 7m50s
Continuous / Continuous Pre-Release (push) Successful in 4m8s
8493dd7578
The scheduled run on 2026-10-05 committed `<!-- sponsors --><!-- sponsors -->` over four sponsors
and reported success. The same job, the same step list and the same `GH_TOKEN` updated the
contributor table in that very commit, which rules out an expired or wrong-forge credential and
leaves the one thing `repo` and `write:packages` do not grant: `read:user`, the scope GitHub
requires for the GraphQL `sponsorshipsAsMaintainer` field. Nothing about the move to Forgejo is
involved -- the action talks to api.github.com directly and never asks the host forge anything.

Why it was silent rather than red: `getSponsors` returns `data.json()` without reading the HTTP
status or the `errors` array, so a scope rejection and an account with no sponsors arrive at
`generateTemplate` as the same thing -- no `viewer` key. It writes the empty `fallback` between the
markers and `generateFile` returns SUCCESS either way.

Ask the API before touching README.md, and fail on an answer that is not a sponsorship count.
Measured against four canned response bodies with `curl` stubbed on PATH:

  INSUFFICIENT_SCOPES  exit 1, echoing GitHub's own message naming read:user
  totalCount 4         exit 0, "Griefed has 4 sponsorships visible to this token."
  totalCount 0         exit 0 -- a real answer, which is the case the action conflates with failure
  Bad credentials      exit 1

The job stays red until `read:user` is added to `GH_TOKEN`; that is the point. `develop` still
carries the populated block, so the next green run restores it with no hand edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci(News): use different URL for release news webhook
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m27s
Documentation / Dokka HTML (push) Successful in 4m7s
Continuous / Build JAR (push) Successful in 11m34s
Grinder Container IT / containers (push) Successful in 17m46s
Qodana / scan (push) Successful in 19m29s
Continuous / Build Windows app-image (x86_64) (push) Successful in 21m22s
Docker Test / build image (push) Successful in 33m20s
Continuous / Build AppImage (x86_64) (push) Successful in 3m14s
Test / build (push) Successful in 14m13s
Continuous / Build AppImage (aarch64) (push) Successful in 3m39s
Qodana / notify (push) Successful in 1m14s
Documentation / Help image (push) Successful in 14m19s
Continuous / Build Install4J Media (push) Successful in 9m34s
Continuous / Continuous Pre-Release (push) Successful in 6m51s
81adb1b3db
Signed-off-by: Griefed <griefed@griefed.de>
docs(readme): a chapter for the four experimental plugins
Some checks failed
Documentation / Writerside webhelp (push) Successful in 3m33s
Documentation / Dokka HTML (push) Successful in 5m20s
Continuous / Build JAR (push) Successful in 13m4s
Grinder Container IT / containers (push) Failing after 13m58s
Qodana / scan (push) Successful in 15m15s
Docker Test / build image (push) Successful in 31m21s
Continuous / Build AppImage (x86_64) (push) Successful in 2m27s
Continuous / Build AppImage (aarch64) (push) Successful in 2m55s
Documentation / Help image (push) Successful in 12m35s
Qodana / notify (push) Successful in 40s
Continuous / Build Install4J Media (push) Successful in 8m46s
Test / build (push) Successful in 44m17s
Continuous / Continuous Pre-Release (push) Has been cancelled
Continuous / Build Windows app-image (x86_64) (push) Has been cancelled
022574a8a5
commit b5463cbaddaf5bf988aa288965946a9516c8b45e
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 12:34:11 2026 +0200

    docs(readme): condense chapter 6, and stop miscrediting chapter 7

    Chapter 7 is about consuming `serverpackcreator-api` as a maven dependency, not about the plugin API,
    so both references to it were wrong: the opening line claimed it "describes the plugin API", and 6.4
    sent would-be plugin authors there. Plugin-API readers now go to HELP.md's *Plugins* chapter, which
    is where that material actually lives.

    Condensed 138 lines to 92. The four "what experimental means" bullets and the separate
    testing/installing headings became one short paragraph each; every per-plugin section lost its bullet
    list in favour of a single description plus the "rough edges" line, which is the part a reader is
    actually here for. 6.3 also carried a duplicated opening paragraph -- an earlier edit replaced its
    screenshot with a file listing and left the sentence it was meant to supersede.

    No claims changed, no sections dropped, every figure still resolves.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 378c0d48b86ce960dfe19f0efec3104fef59297f
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 12:13:05 2026 +0200

    docs(readme): a chapter for the four experimental plugins

    Four plugins are built in this repository and ship with every release, and until now the README said
    only "an example plugins is available with every release" -- singular, stale, and the one of the four
    that is documentation rather than a feature. Chapter 6 covers all four: what each does, what
    `_experimental` in the filename is promising and what it is not, how to install and disable one, and
    the rough edges each is known to have. API and Awesomesauce move to 7 and 8; nothing linked to either
    by anchor, so no cross-references needed fixing.

    The framing Griefed asked for is that these are not guaranteed to work and that testing them is how
    they stop being experimental -- so the chapter says both plainly rather than burying the caveat.

    Screenshots are from a real run: the four plugin jars installed into a throwaway home, a server pack
    generated from the Survive_Create_Prosper_4 fixture, and the GUI driven to each tab.

      plugin_servertest.png  the generated pack listed, Forge 36.2.4 / MC 1.16.5, "Ready to launch",
                             with the start-script dropdown resolved to start.sh
      plugin_example.png     the Tetris tab and the global config panel, both extension points in frame
      plugin_grinder.png     the verdict table against a local grinder store of 875 real verdicts

    The grinder shot shows "0 confirmed, 875 other verdicts" because the store it was served predates the
    `verdict` field (it is from 2026-07-31; `verdict` arrived alongside `confidence` later), so every row
    falls outside the Confirmed list. The UI, the ten columns and the controls are all genuine; re-shoot
    against a live daemon to fill the table, replacing img/plugin_grinder.png and nothing else.

    Self-extract has no interface to photograph -- it is a PostGenExtension and nothing else -- so its
    section shows the files it actually wrote during that run instead of a picture of a directory.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: Griefed <griefed@griefed.de>
fix(api): forward a stop signal to the server in the bash start script
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m3s
Documentation / Dokka HTML (push) Successful in 5m10s
Continuous / Build JAR (push) Successful in 10m6s
Docker Test / build image (push) Successful in 13m18s
Grinder Container IT / containers (push) Successful in 11m34s
Documentation / Help image (push) Successful in 3m11s
Qodana / scan (push) Successful in 12m50s
Continuous / Build AppImage (x86_64) (push) Successful in 2m25s
Continuous / Build AppImage (aarch64) (push) Successful in 2m31s
Qodana / notify (push) Successful in 1m4s
Continuous / Build Install4J Media (push) Successful in 7m2s
Test / build (push) Successful in 20m55s
Continuous / Build Windows app-image (x86_64) (push) Successful in 3m16s
Continuous / Continuous Pre-Release (push) Successful in 3m30s
8f2ac90ce1
commit ddd5b1617ae7029d182d7fc2f01ee8721ba9c2ab
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 14:45:48 2026 +0200

    docs(grinder): why the signal fix stops at the bash template

    Records what was measured against fish 4.6.0 while trying to port it: an
    --on-signal handler does not run during a foreground command, `wait $pid` does not
    wait, `wait` with no arguments waits but reports 0 whatever the job did, and
    `begin … end &` sets no `$last_pid`. So fish can have signal forwarding or a
    faithful exit status, not both — and its run loop exits with that status by design,
    so backgrounding would silently turn every crash into a clean shutdown.

    PowerShell is a different case entirely: the .ps1 targets Windows, which has no
    SIGTERM.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit d1ec0fca560c553756d5111c28892a109babac92
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 14:45:32 2026 +0200

    fix(api): forward a stop signal to the server in the bash start script

    Turns the previous commit's seam into a real handler: `stopServer` hands the request
    to the running server and waits for it, and `runJavaCommand` backgrounds the server
    so the shell can enter that handler at once instead of only after the server
    returns. Exit status is unchanged -- `wait` reports what a foreground run would.

    `0<&0` is load-bearing and is the trap this fix sets for itself. With job control
    off a shell redirects an asynchronous command's input from /dev/null, so
    backgrounding the server to make it signallable would silently cut the console off
    from every command an operator types. Verified under bash:latest: `cat & wait`
    reads nothing, `cat 0<&0 & wait` reads its input.

    The guard now goes red for each half independently, checked by mutation:
      - handler forwarding nothing (today's shipped behaviour)  -> FAILED
      - `"$JAVA" ${1} &` without the fd-0 redirection           -> FAILED

    The guard itself was repaired before being trusted. Its first form collected the
    console from a pipe and lost exactly the lines it asserts on: the JDK closes the
    pipe as the process exits, so the reader threw `Stream closed` on output the
    handler had already written. It reads a file now.

    api suite: 504 tests, 0 failures.

    Only the bash template is fixed. fish and PowerShell cannot take this shape, for
    measured reasons recorded in the grinder's container CLAUDE.md -- fish defers an
    --on-signal handler until the foreground command returns, and its `wait` reports 0
    whatever the job did, which the fish run loop exits with.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 22e52c34e1e85f8648e59fe2fc7bb9b15edbf913
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 14:34:10 2026 +0200

    test(api): pin that a stop signal reaches the server, red

    A pack's PID 1 under docker or systemd is start.sh, and the kernel DISCARDS a
    default-action signal aimed at PID 1 -- so a script that neither traps TERM nor
    execs the server swallows every stop request and the server is SIGKILLed when the
    window expires, losing the world save. Measured with `docker stop -t 5` against the
    two shapes: no trap, 5096 ms and exit 137; a trap plus `wait`, 72 ms and exit 0.

    The guard executes the template's own `stopServer` and `runJavaCommand` against a
    stand-in server that reports what it read from stdin and announces the signal.

    Committed red, and the red is the logic rather than a missing symbol: this carries
    the seam -- a `stopServer` that exits without forwarding anything, plus its `trap`
    registration -- so the failure is `SIGTERM to the script must be forwarded to the
    server so it can save and exit`, with `CHILD-STDIN:say hello` already in the
    console above it. Without the seam it fails on `template default_template.sh has no
    function stopServer`, which says nothing about behaviour.

    VACUOUS AGAINST THIS SEAM: the stdin assertion. The server still runs in the
    foreground here, so fd 0 cannot be anything but the script's own; it only becomes
    load-bearing in the next commit, which backgrounds the server to make it
    signallable and must re-attach fd 0 by hand. A shell with job control off redirects
    an asynchronous command's stdin from /dev/null, so without that redirection the
    server console silently stops working -- verified under bash:latest, `cat & wait`
    reads nothing where `cat 0<&0 & wait` reads its input.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 33c1d79891f0bc2be6cb8e5a63d2074b14d26387
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 14:27:07 2026 +0200

    docs(grinder): the graceful stop is sent, but no boot can honour it

    `stopThenRemove`'s doc claimed the `docker stop` is "what gives a Minecraft server
    the chance to save its world". It is not, for the containers this engine actually
    boots. A boot's PID 1 is `bash start.sh`, the shipped `default_template.sh` installs
    no trap and launches the server as a child rather than with `exec`, and the kernel
    discards a default-action signal aimed at PID 1 -- so the SIGTERM reaches nothing,
    the full window elapses, and the server is SIGKILLed anyway.

    Measured against docker 29.7.2, `docker stop -t 5` on the two shapes:

      sh -c 'sleep 600 & wait'                        5096 ms, exit 137  (production)
      sh -c "trap 'exit 0' TERM; sleep 600 & wait"      72 ms, exit 0    (the IT fixture)

    Found while fixing that IT's own race. It passes because its fixture installs a
    trap, which no boot does -- so it guards the engine's half of the contract and is
    silent about the half that decides whether a server ever gets to save. The doc now
    says which half it is, and the package CLAUDE.md carries the measurement, the two
    costs (15 s per in-flight boot at every shutdown; a user's pack has the same shape)
    and the fix that is NOT taken here: a `trap` plus `wait` or `exec` in the three
    shipped start-script templates changes every generated pack, which is Griefed's
    call rather than a drive-by.

    Also corrects the companion object's summary, which listed a suspend-gap threshold
    that lives in -clientside's SuspendAwareDeadline.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

commit 292024531c98fcb9c0c444aaba20b4b449dca03f
Author: Griefed <griefed@griefed.de>
Date:   Wed Oct 7 14:26:54 2026 +0200

    fix(grinder): the signal IT waits for the daemon, not for the trap it depends on

    `closeSignalsAContainerBeforeKillingIt` gated on `waitForContainerOf`, which returns
    as soon as the daemon reports the container running. Its assertion depends on a
    strictly later event: the shell having executed its `trap`. A SIGTERM landing in
    between is DISCARDED -- the kernel drops a default-action signal aimed at PID 1 --
    so the container survives its own stop, the grace window expires, docker SIGKILLs
    it, and the test reads that as "the engine never signalled".

    The fixture's own script already prints a line to mark the trap being installed;
    nothing waited on it. It now does, via a latch fed by the same `onLine` sink that
    watches for the handler's marker, and both markers are companion constants so the
    shell that writes them and the sink that waits on them cannot drift.

    Measured against docker 29.7.2 on an idle host, where the window is hidden by
    `waitForContainerOf`'s 200 ms poll granularity -- the ready line arrives at +84 ms
    and the gate only opens at +242 ms:

      before, trap delayed behind `sleep 2` to open the window:  3 of 3 FAILED,
              container exit 137, console [starting-up, ready-to-be-stopped]
      after,  same delayed trap:                                 3 of 3 passed
      after,  unmodified fixture:                               10 of 10 passed

    The guard keeps its teeth: with `stopThenRemove` mutated back to a bare
    `removeContainerCmd(force)` -- the regression it exists to catch -- it fails 3 of 3.

    Also drops the fixture's `while true; do sleep 1; done` for `sleep 600 & wait`. A
    trap runs only once the current foreground command returns, so the loop spent up to
    a second of the 5 s grace on shell granularity before entering the handler at all.
    `docker stop -t 5` against an identical trap: 1037 ms looping, 72 ms waiting. Suite
    time per run went 1130 ms -> 630 ms.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: Griefed <griefed@griefed.de>
Griefed deleted branch develop 2026-10-07 18:27:47 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!702
No description provided.