...or you live long enough to see... #713

Merged
Griefed merged 37 commits from develop into main 2026-10-16 23:15:32 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-10-12 16:01:21 +02:00
`Mirror release outward` polled GitHub for five minutes for a release commit
that a Forgejo push-mirror delivers on its own schedule. `Sync when new commits
are pushed` is an opt-in checkbox, and without it that schedule defaults to 8h
-- up to ninety-six times the poll's own patience. The probe diagnosed this
correctly every time and failed the release anyway.

Measured over the 12 release builds from 9.0.0-alpha.6 to 9.2.1: the job
reached its steps 8 times and failed 6, and 5 of those 6 are this one stall --
alpha.7 (run 311), alpha.9 (755), beta.3 (901), 9.0.0 (1063) and 9.2.1 (1411),
each 20/20 attempts of `422 No commit found for SHA`. The sixth is alpha.6's
since-deleted GitLab step. That only timing was ever missing is proven by the
9.2.0 tag, which GitHub holds today -- delivered by the mirror's own interval,
hours after the workflow had given up on it.

So ask Forgejo to push rather than waiting to see whether it has.
`POST /repos/{owner}/{repo}/push_mirrors-sync` is synchronous: the handler
loops the repository's mirrors calling SyncPushMirror inline rather than
queueing it, and that runs `git push --mirror --force`. A 200 means every
mirror holds the release commit and its tag by the time the call returns. One
call covers every mirror the repository has, so a second outward forge needs no
workflow change -- which is why this is preferred over probing each target.

The step is best-effort by design: the handler returns 500 on the first mirror
whose push fails and stops iterating, and the API has no per-mirror sync to
route around a dead one, so the per-forge probe stays the verdict. It prints
each mirror's sync_on_commit, interval, last_update and last_error either way --
last_error being the exact text the web UI shows under Settings -> Mirror
Settings, which the old failure message sent an operator to go and read by hand.

The poll that follows is resized for what it now measures -- GitHub indexing a
push it has already received -- from 20 attempts to 8, and its error points at
the mirror table rather than at the web UI.

Verified by extracting both steps' scripts through a YAML parser and executing
them against a stubbed API: sync 200 with a healthy mirror, sync 500 with a
broken one, and the probe at 200, 401 and exhaustion. That is also what surfaced
the four output defects fixed here -- Forgejo's error body carries no trailing
newline, Python printed `True` where the API says `true`, a never-synced mirror
printed `None`, and `::error::` is only an annotation when it starts a line,
which until now depended on GitHub choosing to end its response with a newline.

misc/ci-duplication-check.py: 88 -> 89 distinct step bodies, 3 duplicated and
unexplained and 4 allowlisted both before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1300. Two patch releases; the catalog's `kover` version is the only
literal, so nothing else moves with it.

Verified by running the thing it builds rather than by the build succeeding:
`:serverpackcreator-api:koverXmlReport` emits a report with real counters --
149 of 162 classes, 8010 of 9542 lines, across 21 packages. A plugin that
loaded but produced nothing would also have exited 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1301. A major, but the release notes make it the bump this build
specifically wants: 1.0.0 "removed direct references to deprecated
JvmVendorSpec.IBM_SEMERU, to prepare for Gradle 9 compatibility", and the
wrapper here is Gradle 9.7.1. It also shades its implementation dependencies,
which removes a classpath-conflict route into a build that puts plugin jars on
buildSrc's compile classpath. Requires Gradle 7.6+ and Java 17+ to run; this
build is 9.7.1 on Java 21.

`buildSrc/settings.gradle.kts` declares the same plugin deliberately WITHOUT a
version, so it inherits whatever the root put on the classpath and needs no
edit -- Dependabot's one-line change is complete here. The landmine in
.claude/rules/build-layout.md that records why both declarations exist quoted
"0.8.0", which would go stale at every bump; it now says "an explicit version"
instead.

Verified against a real runtime rather than by the build going green, because
a toolchain resolver that silently stops resolving looks exactly like one that
was never asked. A scratch project requesting JavaLanguageVersion 99 -- which
nothing can satisfy -- distinguishes the two by error text:

  with foojay 1.0.0  "No matching toolchain could be found in the configured
                      toolchain download repositories."
  without the plugin "Toolchain download repositories have not been configured."

So 1.0.0 registers and is consulted. `./gradlew help` and `javaToolchains`
confirm the root build and buildSrc both still evaluate, with
Auto-download: Enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1302, plus the half of it Dependabot could not see. That PR moves
`eslint` alone and leaves `@eslint/js` at ^9.39.2. In v10 the two packages
decoupled -- eslint 10.12.0 no longer depends on @eslint/js at all, and
@eslint/js is independently versioned at 10.0.1 -- so taking the PR as written
pairs eslint 10 with v9 rule definitions. npm would not have complained:
@eslint/js 9.39.5 declares no peer on eslint, while 10.0.1 declares
`^10.0.0`. The failure mode was a silently stale `js.configs.recommended`,
not an install error. Both move here.

Nothing else had to move. typescript-eslint 8.71.1, eslint-plugin-vue 10.11.1
and vue-eslint-parser 10.4.1 were already locked at versions declaring
`eslint: ^8.57.0 || ^9.0.0 || ^10.0.0`, and @vue/eslint-config-prettier wants
only `>= 8.21.0`. eslint 10 needs Node `^20.19.0 || ^22.13.0 || >=24`; the
toolchain pins 24.18.1.

Verified by asking what the linter would catch, not whether it exited 0 -- a
major that silently matches no files, or loads no rules, passes either way.

  file count   31 before, 31 after (21 .vue, 10 .ts), 0 errors, 0 warnings
  rule set     `--print-config` on a .ts file: 47 core rules on, 20 TS rules on
  teeth        planted violations, each caught by a different config layer:
               no-constant-condition + no-empty  (@eslint/js 10.0.1)
               @typescript-eslint/no-unused-vars, no-explicit-any
               vue/require-v-for-key             (eslint-plugin-vue)
  build gate   `quasar build` runs eslint through vite-plugin-checker; a
               planted `any` fails the build under eslint 10, so the checker
               integrates with the new API rather than quietly no-opping

A first probe caught nothing and was my error, not eslint's: no-dupe-keys and
no-unreachable print as `[0]` in the resolved config, because typescript-eslint
disables core rules TypeScript already reports.

Side effect worth recording: eslint 10 drops @eslint/eslintrc, the legacy
eslintrc compatibility layer, and its tail goes with it -- brace-expansion
4 paths -> 1 and minimatch 4 -> 1, leaving only the modern 5.0.12 / 10.2.6.
The tree shrinks 557 -> 541 packages.

Vulnerability surface unchanged, measured against the npm bulk advisory
endpoint on both lockfiles: 1 package / 1 advisory before and after, `braces`
3.0.3, which GitHub has already closed as auto-dismissed (alert #264). npm's
own "4 high" counts that one advisory's paths, not advisories.

`./gradlew -Pversion="develop" build` BUILD SUCCESSFUL in 2m 32s -- 2168 JVM
tests 0 failed, frontend 38/38 through checkFrontend, assembleFrontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`licenses/LICENSE-AGREEMENT.txt` and the app resource copied from it are
written by `generateLicenseReport`/`copyLicenseReport` on every build, and both
were tracked. `.gitignore` already carried `licenses/*` -- it simply does not
apply to a file git is already tracking, which is why the sibling
`licenses/LICENSE-AGREEMENT` was correctly ignored while the `.txt` was not.
The asymmetry was accidental.

Two ways that cost something:

- Any build with a non-default `-Pversion` dirties the tree. The renderer
  writes `ServerPackCreator ${project.version} license:`, so the documented
  verification command `./gradlew -Pversion="develop" build` rewrites
  `dev` -> `develop` in two tracked files every run. Easy to commit by accident.
- Worse, running `./gradlew generateLicenseReport` ALONE truncates the notice.
  The report covers whatever projects that invocation configured, so a narrow
  request produces a legally empty document. Measured: committed 300,472 bytes
  listing 43 dependencies, standalone run 25,154 bytes listing ZERO. Nothing
  warned, and the result was commitable.

So the fix is to generate them and track neither. `.gitignore` gains the app
resource; `licenses/*` already covers the other.

That removes the checked-in copy that was masking a second defect, so it is
fixed here rather than left behind. `processResources` depends on
`:copyLicenseReport` directly, and `copyLicenseReport` was wired only as
`generateLicenseReport.finalizedBy(...)` -- a finalizer runs the copy after the
report but does not make the copy REQUIRE it. With nothing committed, that
packaged a jar with no notice at all. Measured before the fix: source deleted,
`:copyLicenseReport` -> `NO-SOURCE`, BUILD SUCCESSFUL, destination absent, and
the About dialog falls back to "Could not read resource". `copyLicenseReport`
now `dependsOn` its producer.

The guard for the remaining path took three attempts, and the first two are
worth recording because both LOOKED like guards:

  doLast inside copyLicenseReport    never runs -- a Copy with an empty source
                                     set is NO-SOURCE, and a skipped task runs
                                     none of its actions. BUILD SUCCESSFUL.
  inputs.file inside copyLicenseReport  same. NO-SOURCE is decided before the
                                     declared input is snapshotted.
  doFirst on processResources        FAILS, with the diagnostic.

Both dead versions were run against a deleted source before being replaced, which
is the only reason they were caught; each would otherwise have read as coverage.

Verified:
  red    source deleted + producer excluded -> BUILD FAILED naming the file
  green  nothing on disk, `:serverpackcreator-app:processResources` alone ->
         generateLicenseReport, copyLicenseReport, notice present
  clean  `./gradlew -Pversion="develop" build` BUILD SUCCESSFUL, 43 dependencies,
         300,476 bytes -- byte-identical to the committed file but for the
         4-character version string -- and `git status` free of licence churn

Known and unchanged: the report lists 43 dependencies under a full `build` and
40 under `:serverpackcreator-app:processResources` alone, because the plugin
reports on the configurations that invocation resolved. Releases run the full
build, so the shipped artifact is the complete one, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1303. A patch release (2026-10-08), bug fixes only; nothing in its
changelog touches the JVM backend this project compiles for. One catalog entry
covers the compiler, the allopen/jpa/spring plugins and the stdlib/reflect/test
libraries, so the whole set moves together by design.

The risk worth checking was buildSrc, not the modules. `buildSrc/build.gradle.kts`
puts `libs.plugins.kotlinJvm.marker()` on its own COMPILE classpath, and
precompiled script plugins compile with Gradle's EMBEDDED Kotlin -- which is how
the install4j 13 bump once made `./gradlew help` fail outright with "binary
version of its metadata is 2.3.0, expected version is 2.0.0". A patch bump does
not move the metadata version, and `./gradlew help` was run first to confirm it
rather than to assume it.

`./gradlew -Pversion="develop" build` BUILD SUCCESSFUL in 2m 55s, 110 of 135
tasks executed, so this is a real recompile rather than a cache hit: 2168 JVM
tests 0 failed / 28 skipped, frontend 38/38.

No new compiler warnings, checked as a SET rather than a count -- 44 before and
44 after could have been a coincidence of two different sets. The emitting files
are identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1305. Eleven GUI files lay out with MigLayout, all in -app's Swing
window.

The jar's target was worth checking rather than assuming: 11.5.x "upgraded to
java 21" upstream and 11.5.1 is built on JDK 25 (`Build-Jdk-Spec: 25`), but its
class files are major 65 -- Java 21 -- so it stays within this project's target.
A Java-25-targeted jar here would have been a runtime failure, not a build one.

`./gradlew -Pversion="develop" build` BUILD SUCCESSFUL, 2168 JVM tests 0 failed
/ 28 skipped, frontend 38/38, and `dependencyInsight` confirms 11.5.1 is what
the app's runtimeClasspath actually resolves rather than something a constraint
pinned back.

KNOWN GAP, stated rather than papered over: none of that looks at layout, and
this range is not purely internal. Between the two tags upstream landed "Pad
width as well as height in SwingComponentWrapper.getBaseline()", a visual-padding
clipping fix (PR #94) and an HTML-button layout-loop fix (PR #120), alongside
performance work that memoizes the pixel unit factor and default visual-padding
keys. Any of those can shift pixels. This module's tests are view-model tests by
design -- Swing views stay dumb -- so a green suite says the bump compiles and
breaks no tested behaviour, and says nothing about how the window looks.

The technique for closing that gap is recorded in serverpackcreator-app/CLAUDE.md
(drive Swing from inside the test JVM, which is not headless here, and take
stills with `screencapture`). It was not run: doing it against a real
`bootRun` touches the user's actual ServerPackCreator home directory, which is
not a side effect to take unasked for a dependency bump. First visual
confirmation is therefore the next GUI launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub PR #1304, regenerated rather than applied. The wrapper was bumped with
`./gradlew wrapper --gradle-version 9.8.1` run TWICE -- the first pass rewrites
the properties, the second runs ON 9.8.1 and regenerates the jar and scripts
from that distribution, which is the only way the three files are guaranteed
consistent with each other. The resulting file set matches the PR's exactly
(jar, properties, gradlew.bat; the unix `gradlew` is unchanged at this version).

The committed binary is verified rather than trusted: its SHA-256 is
3b8a25775a69158b5ad2b1d17a80a88dc7b40a352a73eb27d06c612a7ce68e98, byte-identical
to gradle-9.8.1-wrapper.jar.sha256 as published by services.gradle.org. A jar is
the one file in this repository nobody can review by reading it.

Nothing else pins a Gradle version: every `gradle/actions/setup-gradle` in both
workflow directories passes `gradle-version: wrapper`, and no Dockerfile, script
or `misc/` reference names one. So this is the single source, as
`misc/Java-References.md` is for Java.

`./gradlew -Pversion="develop" build` BUILD SUCCESSFUL in 2m 34s, 127 of 135
tasks executed: 2168 JVM tests 0 failed / 28 skipped, frontend 38/38, and the
Kotlin warning set is identical to 9.7.1's, compared as a set rather than a
count.

9.8.1 surfaces one NEW deprecation, and it is worth recording that neither
deprecation this build reports is ours -- both come from third-party plugins,
traced by stack rather than guessed at:

  Configuration.setVisible(boolean)  NEW, removal in Gradle 11
                                     kotlinx.kover ConfigurationsKt.asBucket
  Project.getProperties              pre-existing, ERROR in Gradle 10
                                     de.comahe.i18n4k I18n4kPlugin.kt:235

The second is the one that matters: the "incompatible with Gradle 10" line this
build has been printing all along is i18n4k's, so the Gradle 10 upgrade waits on
that plugin, and anyone hunting for it in build.gradle.kts will not find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven symbols named in doc comments resolve to nothing. Each was verified by
grepping the repository with comment lines stripped and finding zero
occurrences in code: aggregateFor, shouldRecheckCrash, LoaderSupportMemory,
BootObservation, metadataClient, printAvailableLanguages, StartArgument,
TabCreateServerPack.clearInterface, mainBody.

Most were naming a retired thing to explain why the current design is what it
is, so the clause was reworded rather than cut -- the constraint is what a
reader acts on, the dead name is not. `shouldRecheckCrash` is the clearest
case: "this is why it is not called shouldRecheckCrash" becomes "this is why it
may not key on CRASHED alone", which is the same warning without requiring the
reader to have seen a function that was deleted.

Two are worth more than a reword.

ExtensionConfigPanel.clear() is PUBLISHED Maven Central API, and its KDoc told
plugin authors the method fires when `TabCreateServerPack.clearInterface()` is
called. Neither that class nor that method exists. Worse, nothing calls
`clear()` at all: every `.clear()` in every main source set is on a collection,
checked exhaustively, and the GUI reaches a panel only through
`serverPackExtensionConfig`/`setServerPackExtensionConfig`. So the doc promised
a contract the host does not honour. It now says so plainly rather than naming
a class that was deleted. serverpackcreator-plugin-example carried the same
sentence -- it is what third parties copy -- and moves with it.

GrinderSpcEnvironmentTest cited `[mainBody]` for the claim "asserts its own
boundedness, so that cannot rot silently". The helper is `grinderMainBody`, and
it does assert exactly that: braces balanced, plus a check that the window did
not run past `main` into the declarations below. A wrong name, not a false
safety claim -- so the fix is the name.

Compiles: api, clientside, app, grinder and plugin-example, main and test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty-one files carried "(refactor Phase 1b)", "extracted from ApiProperties
in refactor Phase 1c" or "the regexes previously duplicated across ...". Which
pass of a restructuring created a class is not something a reader can act on;
the root CLAUDE.md puts that in the commit message and REFACTOR-LOG.md, which
both already have it.

**Only the provenance clause goes.** In most of these the sentence has two
halves and the second is a live constraint -- "Extracted from ConfigurationHandler
(refactor Phase 1c); ConfigurationHandler remains the facade through which
consumers access these checks" keeps everything after the semicolon, because
that is the rule telling a reader not to call the validator directly. Where the
clause was the whole sentence it was replaced by what the type is actually for:
SupportedModloaders now says "anything matching a loader name asks here instead
of carrying its own copy", which is the constraint its own landmine in
serverpackcreator-api/CLAUDE.md states.

Done by script because the shape is mechanical, and the diff was then read line
by line -- which is how two defects in the first attempt were caught and the
whole pass redone rather than patched. `textwrap` defaults to breaking on
hyphens, so a reflow split "storage-engine" across lines in seven places; and
one rule left "Shared by the configuration-groups can share one storage-engine."
Both are absent from this commit: the check is zero lines ending in a hyphen,
and the rewritten text of all 31 blocks was reviewed.

Two sentences this commit deliberately leaves alone -- "before its decomposition
into per-concern validators" and "Previously the class used reference-equality"
-- are prior-behaviour narrative rather than provenance, and belong to the next
commit.

Compiles: api and app, main and test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three shapes the conventions name explicitly, all mechanical enough to be sure
about.

**Commented-out code, 14 lines across 12 files.** Eleven are `//preferredSize =
dimension` in the Swing file-choosers, the rest a disabled
`//checkForUpdateWithApi()`, two `//javaSetting.file = ...` lines bracketing a
settings load/save, a dead `//unzippedModpack = unzipDestination(...)`, a
`//source = "de/griefed/resources/manifests"` inside an isExe() branch and a
stray `// Font("Arial Unicode MS", ...)`. Each was read in context first; none
is referenced by the code around it.

**Two comments whose subject was an earlier comment.** The convention names
this one as its own anti-pattern. SecurityScans explained at length that an
older doc "promised zip-slip and archive-safety checks ... which never existed
in this class" — the fact a reader needs is that archive safety is zip4j's
during extraction and not this class's, which is what it now says in one
sentence. BootVerifier's "the same early return this made for itself when it
owned the scan" described a function that no longer owns the scan; the first
clause stated the whole rule by itself.

**The two sentences commit 8c7ac2719 deliberately left.** Both
characterization-test docs said the tests pin behaviour "before its
decomposition" so "the upcoming restructuring" can be checked — a decomposition
that happened and a restructuring that is not upcoming. They now state the
property that is still true and still load-bearing: these assert what the code
does rather than what it should, so an assertion changing here means behaviour
moved rather than that a test needs updating. InclusionSpecificationTest's
"Previously the class used reference-equality" becomes what reference-equality
*would* cost, which is the reason not to reintroduce it.

Compiles: api test, app main, clientside main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three methods a plugin author MUST implement had no prose at all -- their
KDoc opened straight into `@param`, so the published Javadoc showed a parameter
table and no sentence saying what the method is for or when the host calls it.
This is the first thing a third party reads.

Each now states what it does, who calls it and what the contract is, verified
against the call sites rather than inferred:

  ConfigCheckExtension.runCheck   runs AFTER the built-in validators, on the
                                  same PackConfig instance; returning true
                                  fails the configuration, so the reason has to
                                  go into configCheck as well or the user is
                                  told only that something is wrong.
  TabExtension.getTab             fires once at GUI startup, not per
                                  generation, so the returned tab is long-lived
                                  and owns its state.
  ServerPackHandlerBase.run       sealed into Pre/PreZip/PostGen, which decide
                                  WHEN it fires, not what it is handed; a
                                  PreZip hook writing to `destination` still
                                  lands inside the archive, a PostGen hook does
                                  not.

Two claims were corrected while writing them, which is the reason to check
rather than describe. `ApiPlugins.runPreGenExtensions` catches
`ExtensionException`, `Exception` AND `Error`, so a hook cannot fail a
generation at all -- the doc says that rather than naming only the declared
exception. And the pre-generation directory is only empty on a FIRST run:
regenerating over an existing pack leaves that pack's files there until the
copy overwrites them, which `ServerPackUpdater` exists to manage.

BaseInformation's four members were "Get the name of this plugin. @return The
name of this plugin." four times over. They now say where each value surfaces
-- `name` is what `plugins.log` blames when an extension misbehaves, `version`
is displayed as-is because SPC compares nothing against it.

serverpackcreator-plugin-example moves in the same commit, as its own CLAUDE.md
requires ("documentation-by-example: it must always reflect current API idiom").
24 identical "Get the X of this addon" blocks collapse to one line each, and the
overrides stop restating a contract the base now carries. Its first draft linked
[BaseInformation.name]; that type is not imported there, so the links would not
have resolved and they are gone.

The four SPCListeners now say *when* they fire and what is on disk at that
moment, copying the treatment SPCGenericListener already had.

Verified: `:serverpackcreator-api:dokkaGenerateHtml` generates with no
unresolved reference, api and plugin-example compile, and the measurement is
4549 doc blocks before and after with 2903 documented declarations unchanged --
nothing lost its documentation. Cross-referencing links 1351 -> 1362,
doc lines 23343 -> 23203.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RED ON PURPOSE -- the fix is the next commit, per the pin-first convention.

`orphansIn` looks ahead from the end of a doc block for another doc block, and
skipped blank lines only. A doc block, an annotation, then a second doc block is
the same defect wearing a disguise: dokka still drops the first one, and the
lookahead stopping at the annotation is what hid it.

Exactly one instance exists, and this run names it:

  VersionMeta.kt:384 -- `update()` carries TWO KDoc blocks with `@Throws`
  between them. The older one ("Update the Minecraft, Forge and Fabric metas")
  is dropped; the one dokka keeps is the newer block documenting why the method
  is `@Synchronized`.

The guard is widened here and watched red against that instance first, because
a guard whose teeth were never checked has repeatedly turned out to assert
nothing in this repository. It fails with exactly one path, which is also the
evidence that the widening finds the real defect and not a class of false
positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns c69baa42b green. `update()` had an older block ("Update the Minecraft,
Forge and Fabric metas") and a newer one explaining why the method is
`@Synchronized`, with `@Throws` between them -- so dokka attached the second and
silently discarded the first, taking its `@return` and `@throws` tags with it.

Merged into one block. The newer prose is kept whole, because it carries the
reason the lock exists; the older block contributed only its tags, which now sit
under it. The sentence it opened with said what the method does in words the
newer block already covers, so nothing is lost but the duplication.

`:serverpackcreator-api:test --tests "*KDocAttachmentTest*"` PASSED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers part three of the ask: how far a call goes from something an
implementor can call. `misc/call-depth.py` reads the compiled classes and
reports the distribution; `claude-docs/CALL-DEPTH.md` is the result.

**The hypothesis holds.** 87% of 5,421 public methods bottom out within three
hops and 2.5% go past six. Every deep chain is one of three named shapes --
the settings read (`ApiProperties -> <Area>Config -> PropertyStore`), a
manifest read that may fetch, and the Strangler-Fig facades -- rather than
accidental nesting. No chain from -api points outward, so the inward-dependency
rule holds in the bytecode and not merely in prose.

**Measure from bytecode, not from source.** A name-keyed graph over the Kotlin
sources was tried first and is worthless: it merges every function sharing a
name and produced `checkConfiguration -> setInclusions -> validate -> updateUI
-> updateFont`, an API config check reaching Swing, at a fake depth of 54.

Four things the tool does that a naive version gets wrong, each found by a
measurement disagreeing with a hand-trace rather than by inspection:

  self-calls      a call on `this` prints with NO owner class. Missing it hid
                  every overload delegation -- ServerPackHandler.run reported
                  depth 0 against a hand-trace of 9, and the graph was short by
                  2,808 of its 7,634 edges.
  descriptors     keyed by name alone, Kotlin's default-argument overload chain
                  looks like a cycle and truncates to zero.
  invokedynamic   Kotlin 2.x compiles lambdas and SAM conversions to it.
  subtypes only   resolving an abstract call by name+descriptor alone makes
                  every data class's hashCode() an implementation of every
                  other's; that fabricated 23-hop chains of unrelated value
                  types on the first run.

Two limits are stated in the report rather than hidden: pass-through hops are
counted, so a one-line facade reads as nesting and the raw integer mildly
punishes the extraction work that made this shallower -- read the chains, not
only the number. And a chain through a host-supplied `fun interface`, or
through reflection or pf4j dispatch, is not reachable at all.

**B46 filed, not fixed.** `checkConfiguration` reaches `WebUtilities.downloadFile`
through `getAndSetIcon` -- a call whose contract is "check this configuration"
fetches a CurseForge thumbnail. Timeout-bounded, so a leak rather than a defect,
and separating it is a behaviour change on published API because the check
currently populates the icon as a side effect.

**The Swing contradiction is fixed in writing.** The root CLAUDE.md forbade -api
compile dependencies on Swing outright while -api imports `javax.swing` in five
files, and -api/CLAUDE.md carved out `java.awt` only. The code is right -- a
plugin must be able to ship a GUI tab and only -api is published -- so the rule
now carries the same carve-out, names the five files, and gives the grep that
re-derives them. Every audit so far has rediscovered this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen comments in main sources explained a current design by describing what
the code used to do -- "each list used to cost one findBy per entry", "a
rejected upload used to cost all three writes", "was previously
indistinguishable from one that was never asked for".

**These are not deletions.** Every one of them is load-bearing: it is the
argument against reverting the thing it documents, which is exactly what the
root CLAUDE.md means by "constraints stay, history goes". What changes is the
tense. "Each list used to cost one findBy per entry" becomes "referencing them
instead costs one findBy per entry" -- the same warning, in a form a reader who
has never seen the other version can act on, with the ~550 round-trip
measurement kept because it is what makes the warning concrete.

"used to" turned out to be the wrong thing to grep for: most hits are
"employed to" rather than "formerly" -- "the Java version used to run
ServerPackCreator", "the axis used to lay out the icons", and
SelectionAttribution's "what was previously saved", which is runtime state and
not project history. Those are untouched. So are the LegacyFabric classes and
everything under `web/migration`, whose subject matter *is* older versions.

**A fourth dead symbol was found while doing it.** Commit 0f0851ebc checked
`aggregateFor` and missed plain `aggregate`, which has four comment references
and zero code -- `ClientsideVerifier.aggregate` is `verdictOf`. That is the cost
of grepping for the name you already know about; the four are corrected here,
three of them now resolving KDoc links rather than backticked prose.

Verified: `./gradlew -Pversion="develop" build` BUILD SUCCESSFUL, 2168 JVM tests
0 failed / 28 skipped, frontend 38/38.
`:serverpackcreator-api:dokkaGenerateHtml` reports no unresolved reference.

Measured against the branch point: 4549 -> 4548 doc blocks, the one being
VersionMeta's two blocks merged into the one dokka was already keeping;
documented declarations 2903 unchanged, so nothing lost its documentation;
cross-referencing links 1351 -> 1362; doc lines 23343 -> 23200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: strip @param/@return that only repeat the prose above them
Some checks failed
Continuous / Preparations (push) Successful in 32s
Documentation / Writerside webhelp (push) Successful in 1m44s
Documentation / Dokka HTML (push) Successful in 7m32s
Docker Test / build image (push) Successful in 11m41s
Grinder Container IT / containers (push) Successful in 11m42s
Qodana / scan (push) Successful in 14m48s
Continuous / JARs (push) Successful in 12m38s
Qodana / notify (push) Successful in 12s
Documentation / Help image (push) Successful in 4m13s
Continuous / AppImage (aarch64) (push) Successful in 2m17s
Continuous / AppImage (x86_64) (push) Successful in 2m20s
Continuous / Windows app-image (x86_64) (push) Successful in 3m28s
Continuous / install4j media (push) Failing after 7m9s
Continuous / Stage the release assets (push) Has been skipped
Continuous / Build (push) Failing after 0s
Continuous / Continuous Pre-Release (push) Has been skipped
Test / build (push) Successful in 27m49s
dd27037ebf
237 tags across 74 files (api 171, app 58, plugin-example 6, grinder 2) said
nothing the sentence above them had not already said -- "@return Common JSON
utilities used across ServerPackCreator." under "This instances common JSON
utilities used across ServerPackCreator.", or "@param modId The ID of the mod"
on `modId: String`. Residue of the 2023 Java->Kotlin conversion.

Deliberately conservative. A tag is kept when its text carries anything the
prose does not, and unconditionally when it names a constraint, a default, a
unit, nullability or a KDoc link -- so "@param scan The scan to run; defaults to
the real Nekodetector." stays, as does every `@throws`, `@author` and `@see`.
The estimate going in was ~670; the heuristic removes 237, and the gap is tags
that looked formulaic and are not.

**The hazard was continuation lines.** A `@param` description wraps onto
following lines that do not start with `@`, so a parser treating every non-tag
line as prose both miscounts the prose and, worse, can strip a tag's header
while leaving its continuation behind as a dangling sentence fragment. Each tag
is grouped with its continuation lines and removed or kept as a unit.

**Known consequence, stated rather than discovered later:** a block may now
document some of its parameters and not others. That is the intended end state
rather than an accident -- the convention asks for tags that add information and
nothing else, and the style this repo already treats as correct
(plugin-example's `Example.kt`) carries prose and `@author` with no `@param` at
all -- but a reader used to complete Javadoc parameter lists should know it is
deliberate.

Verified: api, app, grinder and plugin-example compile;
`:serverpackcreator-api:dokkaGenerateHtml --rerun-tasks` reports 0 unresolved
references; `KDocAttachmentTest` PASSED, which is what would catch a block left
structurally broken. Doc lines 23200 -> 22954; prose words 141779 -> 141764,
i.e. the removals are tags rather than sentences; block count and documented
declarations unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`git subtree add` brings the source repository's whole history along with its
files, so the import arrived as a merge carrying 39 homepage commits reaching
back to 2023-05-01. Those commits land in the range semantic-release analyses
for the next SPC release, and `.releaserc.yml` maps their types onto SPC's own
versioning: `breaking: Move ServerPackCreator from griefed.de to its own
website` alone releases 10.0.0 instead of 9.3.0, headlined by a 2023 website
move. Nineteen further typed subjects (1 feat, 5 fix, 6 ci, 1 build, 4 style,
2 improv), thirteen `RELEASE: 1.x` commits and a bare `Formatting` would have
shared that changelog with it -- every one of them about a website, read by
people looking for what changed in the application.

The tree is identical to what the subtree add produced; only the second parent
is gone. The homepage's own history stays where it is looked up, in
https://git.griefed.de/Griefed/serverpackcreator-de.

`git subtree pull --squash` keeps working from the trailers below: it finds
this commit through `git-subtree-dir`, resolves `git-subtree-split`, and merges
only the diff. Verified by squash-merging homepage 1.3.0 back in, which changed
exactly the two files 1.3.1 had added and nothing outside the prefix. A plain,
non-squash `git subtree pull` now refuses with "refusing to merge unrelated
histories" rather than silently re-importing the 39 commits, so `--squash` is
the mode from here on.

git-subtree-dir: serverpackcreator-homepage
git-subtree-split: e96965ff16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The help site and the Kotlin API reference had two homes, neither of them the
project's own: a separate container at help.serverpackcreator.de and GitHub Pages
at griefed.github.io/ServerPackCreator. Both are now one origin, versioned, rebuilt
and republished on every release.

  /help/<version>/   the Writerside webhelp, out of serverpackcreator-help:<version>
  /api/<version>/    the Dokka HTML reference
  /help/latest, /api/latest -> the newest full release

**The website moved into this repository** with `git subtree add`, so its history
came with it (the merge above). Its own CI could not: `.forgejo/workflows` is
all-or-nothing, and two semantic-release trains cannot own one repository. The
homepage is now a thing ServerPackCreator releases, versioned with ServerPackCreator,
and its workflows, `.releaserc.yml`, `renovate.json` and the dead `root/` tree are
gone.

**The documentation is assembled by a script, not by Dockerfile steps**, because
that is the part worth testing: `docs/assemble-docs.sh` can be run against a scratch
directory and the result served and clicked, which a `RUN` buried in a multi-stage
build cannot. It is also why the whole thing could be verified before it ever ran in
CI -- which is how the four defects below were found and fixed before shipping
rather than after.

**`latest` is a SYMLINK, not an nginx redirect.** The LSIO base declares `/config` a
VOLUME and seeds its site-conf onto it ONCE, so a conf baked into the image is
silently ignored by any deployment that already has one -- the redirect would work on
a fresh volume and quietly not work on the live host. A symlink is content, so it
ships wherever the content ships, and nginx follows it (`disable_symlinks` is off).

**Each version's help links its OWN API reference.** `spch.tree`'s Dokka entry has to
stay absolute -- the webhelp viewer only navigates an href carrying a scheme, so a
relative one is client-side routed and dies on "TOC data error" -- so `docs.yml`
rewrites it to `/api/<version>/index.html` before the builder runs. The step asserts
the literal is present first, because `sed` matching nothing exits 0 and would ship
the Pages link silently, and a later step reads `HelpTOC.json` back out of the BUILT
zip, because Writerside rewrites hrefs as it builds and the source saying so is not
evidence the output does. `pages.yml` substitutes nothing: the in-repo value is the
Pages URL, which is what that workflow wants.

**The homepage build lives in `docs.yml` rather than a workflow of its own**, because
it consumes what that workflow produces. In separate workflows on the same tag they
raced: the assembly pulls `serverpackcreator-help:<version>` while `help-image` is
still publishing it. `homepage-image` now `needs: [version, help-image, homepage-build]`
and the race cannot happen.

Four things were wrong before any of it ran, each measured:

- **It fetched the API reference from github.com**, the outward MIRROR, whose release
  for 9.2.1 does not exist -- the `mirror` job has stalled on 5 of 12 releases.
  Measured: GitHub 404, Forgejo 200 at 4,480,323 bytes. Forgejo is the canonical
  origin and the host the job runs on. A listed version needs a published RELEASE,
  not merely a tag; 9.2.0 has the tag and no release on either forge, so it is not
  listed.
- **The image published for a release never contained that release's documentation.**
  The manifest is hand-edited and the tag is cut afterwards, so it can never name the
  version it is built for -- every release would have shipped docs one release stale,
  with `/help/latest` on its predecessor, on a green pipeline.
  `SPC_RELEASE_VERSION` folds it in; a prerelease is browsable but never `latest`.
  Its API reference comes from THIS workflow's `dokka` artifact rather than a release
  asset, because `release-build.yml` uploads that asset from another workflow on the
  same tag -- the same race, one forge further out.
- **`docs.yml` bypassed `resolve-version`**, the action whose header calls itself the
  one place that decides what a build calls itself, so the tag-shape refusal never
  ran. Its `tags: ['*.*.*']` filter compiles to `^.*\..*\..*$`, which `v9.2.2`,
  `test.tag.x` and `release.candidate.1` all match -- each would have published
  `serverpackcreator-help:<junk>` and a site whose API link points at `/api/<junk>/`.
  The same action answers "is this a plain release?", which was then being re-derived
  twice more; both are now reads of one output.
- **`unzip` is not a given on this runner** -- `docs.yml` says so twelve lines above
  the job that calls the script -- and `docker create` RESOLVES a host-platform
  manifest even though it starts nothing, so the help half failed on arm64 with
  "no matching manifest for linux/arm64/v8" before `docker cp` was reached. python3
  is the fallback the rest of this workflow already uses, and the platform is named.

Verified by running it: the manifest folding across four release shapes; the full
assembly against a stub daemon (help/ and api/ for three versions, both `latest`
symlinks relative and pointing at the release, `api/latest` holding the seeded build
tree rather than a download, the nested help/<v>/api dropped); the tag refusal across
seven tags; and 65 bash run-blocks parsed out of the YAML and checked with `bash -n`.
`misc/ci-duplication-check.py` unchanged at 3 duplicated-and-unexplained, 4
allowlisted.

Also here, because the same work found them: the Docker Hub login must NOT set
`registry:` (it files the credential under the hostname instead of the canonical
`index.docker.io/v1/` key, so buildkit pushes anonymously after a login that reported
success) -- recorded on `registry-login`, where it is enforced; and the builder image's
GitLab-era name, recorded beside the `FROM` that uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fix(ci): make the continuous release say why it failed instead of printing a
traceback` fixed the Forgejo step and left the GitHub one, 35 lines below it in the
same file, carrying both blindfolds that commit message names:

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

`-f` prints nothing on an HTTP error, so GitHub's own explanation is discarded and
json.load reports `Expecting value: line 1 column 1`; and without `pipefail` the
pipeline reports PYTHON's status, so curl's non-zero exit is masked and the parse
error is what surfaces. The comment explaining exactly this sits 42 lines above it.

A fix written against one artifact is a fix for one artifact -- the same shape as the
Connector placeholder that hard-coded `META-INF/mods.toml` while a second spelling
existed. When a fix keys on a call site, ask which other spellings of that call exist
before calling it done.

Four calls that could fail without saying why now capture status and body:

  Forgejo asset upload      curl -sf -F attachment=@...      -> expects 201
  GitHub release delete     curl -sf -X DELETE               -> expects 204
  GitHub release create     curl -sf | python3               -> expects 201
  GitHub asset upload       curl -sf --data-binary           -> expects 201

Both GitHub steps gained `-o pipefail`. The delete step keeps its `|| true`: no
release to delete is the ordinary case, not a failure. The create payload moved into
a heredoc, which also keeps it off argv.

Kept separate from the homepage work in the same session deliberately -- it shares no
reason with it, and a commit message needing "and" between two unrelated clauses is
two commits.

Verified: every `run:` block in `.forgejo/` extracted through the YAML parser and
checked with `bash -n` -- 65 parse, 0 fail, which is what confirms the heredoc
terminator survives block-scalar dedenting rather than landing indented and never
matching. The heredoc body parses back as JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dokkaGenerateHtml` was emitting 17 warnings and nothing was reading them. It is now
at zero, project-wide.

**Three of them are a regression this session caused.** `docs: strip @param/@return
that only repeat the prose above them` removed five tags from two files, and three
were the sole documentation of a PUBLIC PROPERTY of a Maven-published class:

  ForgeInstance    @param minecraftVersion    @param forgeVersion
  MinecraftServer  @param minecraftVersion

The tell is in the source without a tool: both blocks still carry `@param` for their
PRIVATE parameters while the public `val`s above them have none -- the sweep kept the
tags documenting what no reader of the generated reference can see and dropped the
ones documenting what they can.

The removals were defensible one at a time; `@param minecraftVersion Minecraft
version.` really does only restate the signature. The conventions anticipate exactly
this: *"A doc that only restates the signature is barely better than none -- but
silence is worse."* So these are written, not restored -- each now says something the
signature does not (what `minecraftClient()` resolves against; Forge's own maven
spelling, `47.4.0` not `1.20.1-47.4.0`; that the version also names the per-version
manifest file the instance caches).

**The other fourteen predate this range** -- `git log 9.1.0..develop -- <file>` is
empty for every file involved -- and are fixed here rather than filed, because a
defect noticed in passing gets fixed. An unresolved `[selection]` link in
`LaunchablePack` that meant `[StartScriptSelection]` (the sealed interface whose
`Missing` arm carries a `reason`, which is the clause that gave it away); ten
`companion object` declarations whose members were all documented but whose container
was not; `SessionState.Exited.exitCode`, whose explanation MOVED onto the property
rather than being copied there so the fact keeps one home; and four `PackTableModel`
overrides of Swing's `TableModel`, written in this model's own terms rather than
restating the interface.

**Why a pass of its own was needed:** Qodana's `KDocUnresolvedReference` sees a
stranded block only when it happens to contain a link that no longer resolves, which
this project has already measured at 4 of 18. Dokka's `Undocumented` is the other half
of the same picture. Running it across all eight modules that apply the dokka
convention, rather than only the published one, is what turned 3 findings into 17.

And a method note worth keeping: an earlier regex scan for dangling tags reported 29
bad `@param`s and 86 bad `@return`s. Every one was false -- the declaration parser
truncated at 40 lines, and the Unit check keyed on an absent `: Type`, which every
expression-bodied function lacks -- and it found none of the three real ones, because
the defect is an ABSENT tag rather than a wrong one. Ask the tool that parses the
language.

Verified: dokka 17 warnings -> 0 across every module; `./gradlew build` SUCCESSFUL,
zero compiler warnings, 2,168 JVM tests + 38 frontend, 0 failures;
`KDocAttachmentTest` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three passes of audit + analyze over `9.1.0..develop`, appended to the two
accumulating evidence logs rather than overwriting them.

**Scoping the range was the first finding.** Of its 111 commits, **39 are imported
website history**: `git subtree add` leaves the source history unrewritten, so those
commits still touch `src/` and `Dockerfile` at THEIR root and interleave by date. A
path test cannot separate them; `git merge-base --is-ancestor <c> e96965ff1` can. The
real subject was 72 commits.

**No convention violations in any of the three passes.** No `refactor:` commit exists
in the range, so the rule this project has broken three times had nothing to violate.
Every defect found was behavioural, and each is fixed in its own goal-shaped commit
beside this one.

Six defects, all measured, three of which would have broken the next release. Three
of the six share one shape -- **a fix or a fact that exists in one place and is needed
in two**: the devbuild traceback fix landed on one of two call sites; the
release-shape decision had three copies; `CI-NOTES.md` was a second, stale copy of
what `claude-docs/CI-SECRETS.md` maintains. That shape now has five recorded
instances here. It is worth asking, of any fix that keys on a call site or a literal,
which other spellings exist before calling it done.

Recorded as verified-clean so a later pass does not re-walk them: the stop-signal
template fix (exemplary -- red pin first, both halves mutation-verified, fish and
PowerShell exclusions measured); the `spch.tree` href templating; secrets and
script-injection surface; the app-vite 3 alias rename and its asset references; the
grinder benchmark commit, which obeys the rule its own predecessor was renamed for
breaking; and all 28 skipped tests with who is supposed to set each gate -- four of
which nobody sets, each with a written reason, which is the answer that rule asks for.

Also refreshed the refactor-state counts, which the table's own header predicts will
go stale: api 503->504, app 232->234, clientside 673->680, grinder 542->543,
web-frontend 37->38. And filed **B38**, the homepage's eslint 8 island -- `.eslintrc.js`
plus five dependencies that no workflow invokes, beside a `"test"` script that is
`echo "No test specified" && exit 0`. Deferred because it is inert: nothing runs it, so
it can neither fail a build nor mislead a reader. Still worth doing, because the
repository now carries both eslint generations.

Closing state: `./gradlew build` SUCCESSFUL, 0 compiler warnings, 2,168 JVM tests +
38 frontend, 0 failures, 28 skipped; `dokkaGenerateHtml` 0 warnings across all eight
modules, against 17 before; 65 bash run-blocks parse;
`misc/ci-duplication-check.py` at 3 duplicated-and-unexplained / 4 allowlisted,
unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces "one logical concern per commit" as the default. A commit is the unit at
which somebody later asks *"why was this done?"*, so everything sharing one answer
belongs in one commit, however many files, modules or languages it spans. Atomic
commits blow up the history: they record the PROCESS of arriving at a change rather
than the change, and a reader paging through twenty of them has to reconstruct the
one intent that produced them.

The rule as written says three things the one-line version does not:

- **The test is a shared REASON, not a shared file.** A workflow edit and the shell
  script it calls are one commit when one goal needs both; two unrelated fixes in the
  same file are two commits.
- **It pays most where the first attempt was wrong.** A feature plus the four repairs
  its own audit then found is ONE commit, because the repairs were never separately
  true -- nothing shipped in between. The history should show the feature that works,
  not a broken one followed by apologies.
- **It is not a licence for a grab-bag.** "Everything I did today" is not a goal. If
  the message needs "and" between two unrelated clauses, it is two commits.

This session is the worked example and supplies the measurement. Eleven atomic
commits became five: the homepage feature and the four defects its own audit found in
it, the unrelated devbuild fix kept separate under the grab-bag clause, the dokka
documentation gaps, the audit record, and this. `CI-NOTES.md` is what makes the case
concretely -- one commit of the session wrote 156 lines of it and a later one deleted
them, so the atomic history recorded a file arriving and leaving. Grouped, it never
existed.

**The pin-first rule is amended rather than left to contradict this**, because it
demanded a separate red `test(...)` commit and grouping can absorb that boundary. The
ideal is unchanged -- a red pin the reader can re-run with `git checkout <fix>^` is
still the only form that proves itself. Where grouping absorbs it, the MESSAGE now has
to carry it: state that the guard was written and observed red first, and quote the
mutation that reproduces the red. `8f2ac90ce` is named as the worked example, a squash
whose message reproduces its own `test(api): pin that a stop signal reaches the
server, red` sub-commit verbatim along with the two mutations that fail against it. A
grouped commit that merely asserts "tests added" has thrown away the evidence the rule
exists to preserve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docker-test.yml` and `test.yml` had identical triggers (`push`,
`workflow_dispatch`) and identical concurrency semantics (`<name>-${ref}`,
cancel-in-progress). They were one workflow wearing two names, and a reader
asking whether a commit is green had two places to look. The image check is now
`test.yml`'s `docker` job, moved verbatim with its comments -- including the one
explaining why it deliberately does not push.

**`grinder-container-it.yml` is NOT folded in, and that is structural rather
than a preference.** Its concurrency group has no ref in it and
`cancel-in-progress: false`, so exactly one run happens at a time across the
whole repository; `test.yml`'s is per-ref and cancellable. A workflow can hold
only one concurrency block, so merging means picking one of those -- and
**Forgejo has no job-level concurrency**. That was read rather than assumed:
`ConfigureActionRunConcurrency` evaluates `workflow.RawConcurrency` onto the
`ActionRun`, and `models/actions/run_job.go` does not mention concurrency once.

Both outcomes of merging it anyway are already on this repository's record.
Dropping the repo-wide lock re-opens the interference that failed runs 947, 954
and 956 on a *different* test of the same class each time -- which is the
signature of interference rather than of a defect. Taking the lock for all of
`test.yml` serialises every push, which `.claude/rules/ci-workflows.md` already
says was considered and rejected at the cost of a second full 35-minute run in
series. So it stays until Forgejo grows job-level concurrency, and the rules
file now says so in its own section rather than leaving the next person to
rediscover it.

Documentation that stated present fact was corrected rather than left to rot:
the allowlist entries naming `docker-test.yml`'s retry steps, CI-SECRETS' table
row, `release-build.yml`'s comment citing the retry, RUNNER-REGISTRY-CACHE's
cache-probe sentence, and the "creating alpha/beta starts five workflows" count,
now four. The dated measurements that name the old file -- the saturation table,
the three ghcr 429 runs, the 2026-10-02 `pull_request:` removal -- are history
and keep its name.

misc/ci-duplication-check.py: 89 distinct step bodies, 3 duplicated and
unexplained, 4 allowlisted, all unchanged -- the move preserved every step body
byte for byte, which is why the allowlist's content hashes still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
develop did not contain main, so `git branch --no-merged develop` listed it and
every future back-merge would have had to re-resolve the same thing.

What it brings is **CHANGELOG.md only** -- the `RELEASE: 9.2.0` and `RELEASE: 9.2.1`
commits semantic-release writes on the release branch, 82 lines of release notes,
plus the PR merge between them. No code, no workflow, no configuration.

That matters more than a tidy branch graph: `CHANGELOG.md` is one of the seven root
documents, and two `Copy` tasks in `serverpackcreator-api/build.gradle.kts` mirror it
into the jar (`ApiWrapper.setup()` writes it into the user's home) and into
`serverpackcreator-help/Writerside/topics/`. So a build from develop was shipping a
changelog that stopped at 9.1.0, and the help site built from it would have done the
same.

Merged rather than cherry-picked deliberately: cherry-picking the file would leave
develop still not containing main, so the next release would conflict on the same
lines again.

Clean merge -- verified with `git merge-tree --write-tree` before running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(homepage): pull the website subtree with --squash, or it reopens the changelog
Some checks failed
Documentation and homepage / Version (push) Successful in 36s
Documentation and homepage / Homepage: Quasar build (push) Successful in 3m57s
Grinder Container IT / containers (push) Successful in 9m1s
Qodana / scan (push) Successful in 11m31s
Test / build (push) Successful in 9m46s
Documentation and homepage / Writerside webhelp (push) Successful in 3m20s
Documentation and homepage / Dokka HTML (push) Successful in 4m52s
Qodana / notify (push) Successful in 16s
Test / Docker image (push) Successful in 11m23s
Documentation and homepage / Help image (push) Successful in 2m25s
Documentation and homepage / Homepage: image (push) Has been skipped
Continuous / Preparations (push) Successful in 19s
Continuous / JARs (push) Successful in 4m48s
Continuous / AppImage (x86_64) (push) Successful in 2m45s
Continuous / AppImage (aarch64) (push) Successful in 2m45s
Continuous / Windows app-image (x86_64) (push) Successful in 4m5s
Continuous / install4j media (push) Failing after 6m50s
Continuous / Stage the release assets (push) Has been skipped
Continuous / Build (push) Failing after 0s
Continuous / Continuous Pre-Release (push) Has been skipped
c18d38cb74
`serverpackcreator-homepage` is a subtree of a separate repository, and the mode
it is pulled with decides whether SPC's next changelog is about SPC. A plain
`git subtree pull` merges the website's tip into this history and makes all of
its commits ancestors of `develop`; `.releaserc.yml` uses a bare
`tagFormat: '${version}'`, so semantic-release analyses every one of them and
reads their conventional-commit subjects as SPC's own. The original
`git subtree add` did precisely that, and `breaking: Move ServerPackCreator
from griefed.de to its own website` (2023-05-01) alone would have released
10.0.0 instead of 9.3.0.

Nothing enforces the mode, so the rule goes where it is read: the root module
map for anyone scanning the repository, and a module `CLAUDE.md` that
Claude Code lazy-loads when work is happening in that directory.

The module file records only what the directory's own files cannot answer --
its provenance, the pull command, and that `git fetch`'s tag auto-following is
how the website's `1.0.0`-`1.3.1` tags got into SPC's release namespace. How
the image is built, how the versioned documentation is assembled and which
versions the site serves are already stated in `Dockerfile`,
`docs/assemble-docs.sh` and `docs/versions.json`, so the file points at those
rather than keeping a second copy to go stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed changed title from ---or you live long enough to see... to ...or you live long enough to see... 2026-10-12 16:27:10 +02:00
`spc.install4j` reads `licenses/LICENSE-AGREEMENT.txt` for the installer's
licence screen, and that file is written by `generateLicenseReport`. Nothing in
`media`'s task graph ran it: `media` dependsOn `tasks.build`, which is the ROOT
project's build -- that project has no sources and reaches no subproject, so
`-app`'s own `build { dependsOn(":generateLicenseReport") }` never applies here.

It stayed invisible while the file was tracked, because the checkout supplied
it. `stop tracking the generated third-party licence notice` untracked it --
rightly; it is generated and `licenses/*` was already in `.gitignore` -- and the
first `media` run after that failed:

    install4j: compilation failed. Reason: java.io.FileNotFoundException:
    .../licenses/LICENSE-AGREEMENT.txt (No such file or directory)

Measured, `./gradlew media --dry-run`:

    before  9 tasks, every one in the root project; no :generateLicenseReport
    after   :generateLicenseReport and :copyLicenseReport in the graph

A `dependsOn` that merely makes the file EXIST is not sufficient, and that is
the half worth checking rather than assuming: the commit above recorded that a
narrow invocation of `generateLicenseReport` silently writes a legally empty
notice -- 25,154 bytes listing ZERO dependencies -- so a plausible fix ships
installers with a blank licence screen and reports success. Verified by deleting
both files and running exactly what CI runs:

    baseline     (1 of 43), 300,476 bytes
    regenerated  (1 of 43), 300,476 bytes, byte-identical

End-to-end against install4j 13.1: BUILD SUCCESSFUL in 7m 4s, all three
installers written. The red half is Forgejo run 1056's re-run, whose `install4j
media` job is this code without this edge and fails as quoted above; every other
job in that re-run is green.

9.2.1 does not carry the untracking commit, so no release shipped this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Build the install4j media` step explained the two-fat-jars incident with
"`media` dependsOn `build`, so Gradle writes `serverpackcreator-app-dev.jar`
BESIDE" the restored jar, and justified its `-x` test exclusions the same way --
"without them this job re-runs the whole api and app suite".

Neither can happen. `media` dependsOn `tasks.build`, which is the ROOT project's
build; that project has no sources and reaches no subproject. Measured with
`./gradlew media --dry-run`: 9 tasks, every one of them `:`-rooted, no
`:serverpackcreator-app:` anything. So the step runs no module's test suite and
Gradle writes no second jar -- the exclusions are inert, and the `jars` job's own
comment in this file already gives the surviving explanation for that incident,
a reused runner workspace with no `clean`.

Two unrelated facts for one symptom, in one file, and at most one of them true:
exactly what the "duplicated knowledge drifts toward whichever copy is easier to
reach" rule exists to catch.

The exclusions stay. They cost nothing and become correct the moment `media`
gains a real dependency on a module build -- which is the fact the rewritten
comment puts on the record instead, because it is the one a reader can act on:
nothing in this job builds the application it installs, so it is safe only
because `Download JARs` ran first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(ci): publish a homepage image per commit, like the help image
All checks were successful
Documentation and homepage / Version (push) Successful in 25s
Continuous / Preparations (push) Successful in 37s
Documentation and homepage / Homepage: Quasar build (push) Successful in 2m23s
Grinder Container IT / containers (push) Successful in 9m11s
Qodana / scan (push) Successful in 13m35s
Documentation and homepage / Writerside webhelp (push) Successful in 1m9s
Test / Docker image (push) Successful in 8m59s
Documentation and homepage / Dokka HTML (push) Successful in 5m6s
Qodana / notify (push) Successful in 10s
Documentation and homepage / Help image (push) Successful in 2m35s
Documentation and homepage / Homepage: image (push) Successful in 5m15s
Continuous / JARs (push) Successful in 11m32s
Continuous / AppImage (aarch64) (push) Successful in 3m8s
Test / build (push) Successful in 30m58s
Continuous / AppImage (x86_64) (push) Successful in 3m12s
Continuous / Windows app-image (x86_64) (push) Successful in 4m59s
Continuous / install4j media (push) Successful in 6m39s
Continuous / Stage the release assets (push) Successful in 1m49s
Continuous / Build (push) Successful in 0s
Continuous / Continuous Pre-Release (push) Successful in 3m35s
025e84743c
`homepage-image` was gated on `startsWith(github.ref, 'refs/tags/')`, so the
site only ever existed as an image on a release. The deployment therefore had no
rehearsal: the first run of nginx, the `/config/www` webroot, the `latest`
symlinks and SPA routing against a real assembled tree was release day.
`help-image`, which this job pulls from, has carried no gate at all and has been
publishing per commit for exactly this reason -- the workflow header says so.

The gate becomes the same new-ref guard the other three jobs here carry, byte
for byte. Nothing else changed, because nothing else needed a branch/tag
distinction:

- `:latest` is already impossible on a branch. `Compute tags` appends it only
  when the `version` job reports `prerelease=false`, and `resolve-version`
  hard-sets `PRERELEASE=true` for every branch build. A branch publishes
  `<branch>-<short-sha>`, or `git describe --tags --always` on `main`.
- `/help/latest` already keeps naming the newest RELEASE, because
  `SPC_RELEASE_IS_LATEST` reads that same answer.
- The previous releases are already included. `assemble-docs.sh` walks every
  version in `versions.json` and `SPC_RELEASE_VERSION` unshifts one more onto
  that list, so a preview carries `/help/<branch>-<short-sha>/` beside
  `/help/9.2.1/` and `/help/9.1.0/` -- the same code path a release takes, which
  is what makes it a rehearsal rather than a one-version stub.

The API half for the commit's own version can only come from this workflow's
`dokka` artifact: no release is named `<branch>-<short-sha>`, so there is no
asset to fall back to. That seeding step already existed to dodge a race on the
tag path and is simply load-bearing on both now; the comment says which.

Cost, stated because it is the part that was a judgement call rather than a
consequence: every push now pulls three help images and two release assets and
rebuilds the SPA with `no-cache: true`, on a runner whose saturation is already
documented. Taken deliberately, scoped to the four branches this workflow
already triggers on so that the homepage and help images share one publish rule
instead of having two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore(Homepage): Update CI for IDEA, add links for new help and api doc locations, update links to help
All checks were successful
Documentation and homepage / Version (push) Successful in 33s
Continuous / Preparations (push) Successful in 36s
Documentation and homepage / Homepage: Quasar build (push) Successful in 3m5s
Grinder Container IT / containers (push) Successful in 7m47s
Qodana / scan (push) Successful in 12m26s
Documentation and homepage / Writerside webhelp (push) Successful in 1m13s
Documentation and homepage / Dokka HTML (push) Successful in 4m19s
Test / Docker image (push) Successful in 10m26s
Qodana / notify (push) Successful in 1m5s
Documentation and homepage / Help image (push) Successful in 2m45s
Documentation and homepage / Homepage: image (push) Successful in 4m42s
Continuous / JARs (push) Successful in 9m42s
Continuous / AppImage (x86_64) (push) Successful in 1m54s
Continuous / AppImage (aarch64) (push) Successful in 1m55s
Continuous / Windows app-image (x86_64) (push) Successful in 4m17s
Test / build (push) Successful in 31m19s
Continuous / install4j media (push) Successful in 8m6s
Continuous / Stage the release assets (push) Successful in 2m3s
Continuous / Build (push) Successful in 0s
Continuous / Continuous Pre-Release (push) Successful in 3m25s
d5ca59d10f
Signed-off-by: Griefed <griefed@griefed.de>
Reported from the grinder running under systemd. A truncated forge-manifest.json ended every start
with

  com.fasterxml.jackson.core.io.JsonEOFException: Unexpected end-of-input: was expecting closing
  quote for a string value [line: 4477, column: 11]
      at ...JsonUtilities.getJson(JsonUtilities.kt:219)
      at ...ForgeLoader.update(ForgeLoader.kt:99)
      at ...VersionMeta.<init>(VersionMeta.kt:313)
      at ...ApiWrapper.setup(ApiWrapper.kt:381)
      at ...GrinderApplication.main(GrinderApplication.kt:146)

and Restart=on-failure turned that into a loop. The host has 1.8 TB free, so this was a kill
mid-write rather than ENOSPC -- the background manifest refresh runs at startup, which is exactly
the window a `systemctl restart` lands in.

Two defects, and the second is why it was fatal rather than annoying.

The write was truncate-then-write: updateManifest did create() then writeText(content) in place, so
a killed JVM leaves exactly as many bytes as it managed.

And nothing could ever repair the result. ApiWrapper.setup() seeds the manifests from the jar
through JarUtilities.copyFolderFromJar, whose inner loop is guarded by `if (!destination.exists())`
-- correct for "do not clobber a refreshed manifest", fatal for "this file is a half-write". The
refresh could not undo it either: checkManifest decides on *version counts*, so it parses the local
copy too, threw before the comparison, and discarded the working download already in memory. The
manifest a host could not read was therefore the one manifest it could never replace, and deleting
the file by hand was the only exit.

Prevention. File.writeTextAtomically stages into a sibling and ATOMIC_MOVEs it into place, so a
killed or failed write leaves the previous manifest rather than part of the new one. ManifestUpdater
is its only caller today. Its temporaryFile parameter exists only so the failure can be executed --
the same reason ModpackZipInspector takes an openZip; a partial write cannot be provoked from a test.

Repair, in three places, because each covers what the others cannot:

- ManifestUpdater adopts the upstream copy over a local one it cannot parse, so an online host heals
  to current data rather than to the shipped snapshot. An upstream answer that cannot be parsed is
  still never adopted -- taking it would turn a transient truncated response into the same permanent
  failure.
- ManifestIntegrity.restoreIfUnreadable restores the jar's copy, or deletes a file the jar does not
  carry (the mcserver/<version>.json files), and clears both the .etag sidecar and the modification
  time. The sidecar earns a 304, which is a promise that the local copy IS the upstream one; the
  modification time would otherwise tell the four upstreams that honour If-Modified-Since that "now"
  is newer than anything they have, stranding the host on the snapshot until upstream happened to
  publish.
- VersionMeta.updateOrRepair is the wiring: every meta's update() runs through it, and a failure
  repairs that meta's manifests and retries once. A failure that repaired nothing is RETHROWN
  unchanged, deliberately -- the alternative is an empty meta, and this module has the receipts for
  what an empty serverReleases() costs (every candidate refused with "No bootable combination", i.e.
  the engine's own state published as a statement about a mod).

Deliberately NOT a proactive scan of the manifests directory in setup(): it would parse ~500 KB on
every start for a case that is rare, and the metas parse the same files seconds later anyway. The
repair belongs on the failure path.

Same shape one level down, found while reading the path and fixed here because it shares the reason:
MinecraftServer.readServerJson discards an mcserver/<version>.json it cannot parse. It never crashed
anything -- both callers catch -- which is why it was worse. Every lookup re-read the same broken
file and returned Optional.empty(), which every consumer reads as "this version declares no required
Java": REQUIREMENT_UNKNOWN masquerading as JDK_NOT_BUNDLED, permanently.

Behaviour change on published API, row appended to claude-docs/API-BEHAVIOUR-CHANGES.md. Also:
checkManifest's SAX catch no longer restores from the jar. Only the upstream answer can reach it now
-- an unreadable local copy is handled where it is counted -- and overwriting a working local
manifest because upstream sent rubbish is the opposite of a repair.

Pins written and observed RED first, in one commit because they share one reason. The reds, and what
produced them:

- VersionMetaManifestRepairTest reproduced the host's own message verbatim, from a forge-manifest.json
  truncated to 90,000 bytes: "JsonEOFException: Unexpected end-of-input: was expecting closing quote
  for a string value". The XML half went red with 'SAXParseException: Elementtyp "version" muss mit
  dem entsprechenden Endtag "</version>" beendet werden' from a neoforge-manifest-new.xml truncated
  to 40,000 bytes.
- ManifestIntegrityTest: 5 of 6 red against a stub returning false.
- ManifestUpdaterTest.anUnreadableLocalManifestIsReplacedByTheUpstreamCopy, red against the old count
  comparison -- the local manifest kept its 40 truncated bytes.
- AtomicTextWriteTest.aFailedWriteLeavesThePreviousContentsIntact, red against create()+writeText().
- MinecraftServerManifestCooldownTest.anUnparseableCachedManifestIsDiscardedSoItCanBeFetchedAgain,
  red against the unguarded getJson.

Two seams (File.writeTextAtomically, ManifestIntegrity) were landed stubbed so the red is the missing
behaviour and not a missing symbol -- the third sanctioned route for new code. The first run of
VersionMetaManifestRepairTest went red for the WRONG reason (UnrecognizedPropertyException: the
fixture built a bare ObjectMapper where ApiWrapper disables FAIL_ON_UNKNOWN_PROPERTIES), which is why
the convention says to read why a pin failed before trusting it.

Three of the new guards are GREEN against the old code by design. They are there to stop this change
breaking what already worked, so name them rather than let them be mistaken for pins:
anUnreadableUpstreamManifestIsNotAdopted, aRefreshLeavesNoStagingFileBehind, and
aManifestWithNoModificationTimeIsFetchedUnconditionally -- which pins the JDK behaviour the
modification-time reset depends on (HttpURLConnection omits If-Modified-Since entirely when it is
zero), load-bearing and non-obvious enough not to leave assumed.

Suites, all green: api 520 (from 504), grinder 543 with its 27 gated skips, clientside 680, app 234.
The app suite's first run had 25 failures, every one of them the documented flapdoodle-and-stale-jar
flakiness of three concurrent Gradle builds; clean on a re-run of the same commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(api): a failed download destroyed the file it was replacing, and a killed one cached as complete
All checks were successful
Documentation and homepage / Version (push) Successful in 49s
Continuous / Preparations (push) Successful in 50s
Documentation and homepage / Homepage: Quasar build (push) Successful in 4m13s
Grinder Container IT / containers (push) Successful in 12m42s
Qodana / scan (push) Successful in 18m14s
Documentation and homepage / Writerside webhelp (push) Successful in 4m41s
Test / Docker image (push) Successful in 12m41s
Documentation and homepage / Dokka HTML (push) Successful in 4m40s
Qodana / notify (push) Successful in 24s
Documentation and homepage / Help image (push) Successful in 3m36s
Continuous / JARs (push) Successful in 12m3s
Continuous / AppImage (aarch64) (push) Successful in 2m46s
Continuous / AppImage (x86_64) (push) Successful in 2m49s
Documentation and homepage / Homepage: image (push) Successful in 11m42s
Continuous / Windows app-image (x86_64) (push) Successful in 11m55s
Continuous / install4j media (push) Successful in 9m25s
Continuous / Stage the release assets (push) Successful in 3m0s
Continuous / Build (push) Successful in 0s
Continuous / Continuous Pre-Release (push) Successful in 6m25s
Test / build (push) Successful in 59m32s
84ccf4d196
Follow-up to the manifest repair, same failure one layer down and reached by every artifact SPC
fetches rather than only by the twelve manifests.

`downloadFile` called `file.create()` -- which truncates -- then streamed into the destination, and
`deleteQuietly()`d it when the stream threw. Two consequences, and only the first is obvious.

A download that merely FAILED destroyed the copy the host already had. The destination was truncated
before the connection was even opened, so an unreachable host, a 404 or a dropped connection left
nothing where a working file had been -- an installer, a pack icon, a per-version Minecraft manifest.

And a KILLED download left a partial file, which satisfies `isFile`, which is exactly what
`return file.isFile` reported as success. Every caller reads a `true` as "I now have this artifact":
`ForgeMeta.installerFor` and its NeoForge, Fabric, Quilt and LegacyFabric siblings hand the
destination straight back as an installer and never re-download it (`if (!destination.isFile)`),
`HttpJarDownloader` returns it as a mod jar to be staged, `MinecraftServer` parses it, and
`ModpackManifestParser` adopts it as a pack icon. A truncated installer therefore cached as a
complete one for ever -- the same shape as the truncated manifest, except it is served from cache
rather than parsed on startup, so nothing ever reports it.

`File.writeTextAtomically` generalises into `File.writeAtomically`, which hands a staging sibling to
a writer and `ATOMIC_MOVE`s it into place; the string overload delegates to it and `downloadFile`
streams into it. The return value had to stop being derived from `isFile` in the same change: once
the destination survives a failure, "a file is there" and "this call wrote it" are different
questions, and only the second is the one every caller is asking.

The default staging name is **unique per call**, not derived from the destination. Shared, two
writers interleave their bytes into it and then move the mixture into place -- the corruption this
helper exists to prevent, one path along -- and it is reachable rather than theoretical: the grinder
runs N workers against one shared installer cache and the five `installerFor` methods take no lock.
Given one each, both stage a complete copy and whichever move lands last wins.

Behaviour change on published API; row appended to claude-docs/API-BEHAVIOUR-CHANGES.md. The part
worth reading before upgrading is the preserved file: code that used the deletion as a signal ("the
file is gone, therefore the download failed") must read the return value instead.
`downloadAndReplaceFile` is untouched and still deletes first, by its own documented contract.

Pins written and observed RED first:
- `aFailedDownloadLeavesAnExistingFileIntact` failed with `FileNotFoundException` on the destination
  -- the failed download had deleted the file the test wrote before it.
- `aFailedDownloadReportsFailureEvenThoughTheOldFileSurvives` failed on its second assertion, the
  old file being gone.
- `eachWriteStagesIntoAFileOfItsOwn` failed against the shared `"$name.tmp"`, two writes to one
  destination yielding one staging path.

Two of the five new guards are GREEN against the old code by design, and guard this change rather
than pinning the defect -- `aSuccessfulDownloadLeavesNoStagingFileBehind` and
`aFailedDownloadLeavesNoStagingFileBehind`, which is what stops staging accumulating one file per
attempt beside the artifact it failed to become.

NOT covered by a test, stated rather than implied: the killed-mid-download case itself. A kill cannot
be staged from a test, so it is covered by construction -- partial bytes can only ever land in the
staging file -- and the two staging guards are what prove the mechanism is wired. The same limit
applies to `writeAtomically`'s own contract, which is why its `temporaryFile` is injectable at all.

Suites, all green in one clean sequential run: api 525, clientside 680, grinder 543 with its 27 gated
skips, app 234 — 1,982 tests, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both ends of the same problem: how a server pack's text files are written, and
what the pack does when one of them comes back changed. They are one commit
because the second half is what makes the first half worth anything -- writing
variables.txt correctly does not keep it correct, since the file travels.

WRITING: a generated run-file's endings are chosen by its reader

Every file createServerRunFiles wrote ended its lines by deleting carriage
returns, at four copies of `.replace("\r", "")`. Right for CRLF input, wrong
for everything else, in two separate ways.

A template separated by LONE carriage returns lost its separators instead of
having them converted, collapsing into one line. Measured against the guard:
`#!/usr/bin/env bash\recho one\recho two\r` came out as
`#!/usr/bin/env bashecho oneecho two` -- no shebang, no commands, and still a
file that parses. Nothing reports it.

And start.bat shipped LF-only, because `bat` is in defaultStartScriptTemplates()
and the deletion ran over every template alike. cmd.exe resolves a goto/call
label by scanning the file and misses one crossing a 512-byte boundary when no
carriage return has reset its line counter (dostips.com/forum/viewtopic.php?t=8988,
Dave Benham, reproduced there on Windows 10). The shipped default_template.bat
has no label, so nothing was broken -- but the templates are operator-editable,
which is what makes it worth closing rather than noting.
serverpackcreator-plugin-selfextract's Stubs.batch had already reached the same
conclusion for its own .cmd and joins with \r\n.

String.withLineEndings is now the one place that decides. It normalises to LF
before expanding, so a carriage return is replaced and never deleted, and an
already-CRLF template is not doubled into \r\r\n.
ServerPackProvisioner.lineEndingFor picks the convention from the written
file's extension: CRLF for bat/cmd, LF for everything else. The four writes go
through writeTextAtomically now, so a killed generation leaves the previous
pack's file rather than a truncated one, and variables.txt is made writable
first like its three siblings already were -- without that, the atomic move
would succeed on POSIX (rename needs the directory's permission, not the
file's) and fail on Windows.

A template's own endings no longer reach a user, which makes .gitattributes'
comment about the server_files templates wrong; corrected rather than deleted,
since keeping that directory LF in the working tree still costs nothing.

READING: the pack survives a variables.txt that arrived changed

ServerPackCreator writes that file LF-only and without a byte-order mark and
cannot keep it that way. An FTP client in ASCII or Auto mode rewrites .txt on
upload, a hosting panel's web editor saves what its browser gave it, an editor
on Windows adds what it likes. The start script ships with the pack, so it is
the last point of control.

Carriage returns: sourced as-is, one is attached to every value. "$JAVA"
-version fails as `java\r: command not found`, with a message a terminal
mangles by rewinding its cursor over it, and WAIT_FOR_USER_INPUT is never
equal to "true" however the operator sets it.

A byte-order mark: three invisible bytes that stick to the FIRST setting's
name, so JAVA becomes a name nothing reads and the operator's Java choice is
ignored while every setting below it still works. In bash the line stops being
an assignment and parses as a command.

The two shells failed differently, so one fix was not the fix. Measured
against the shipped functions beforehand: bash broke on CRLF, on lone CR and
on the mark; fish already survived CRLF -- `string trim` counts a carriage
return as whitespace -- and broke on lone CR, which its `read` hands over as
one single line, and on the mark, which it does not count as whitespace.

Three choices in there that look arbitrary and are not:

  eval "$(tr ...)", not source <(tr ...). bash 3.2, which is what macOS ships,
  sets NO variable at all from a process substitution handed to source, and
  reports nothing: every value comes back empty and the pack starts with its
  settings gone. Verified on 3.2.57 (silent, all empty) against 5.2.15 (works).
  That was the first spelling tried and would have shipped a worse bug than
  the one being fixed.

  tr '\r' '\n', not tr -d '\r'. Same rule as the writing side: deleting the
  separator collapses a lone-CR file into one line, which still parses.

  The mark is stripped anchored to the start of the file, never deleted
  globally -- those bytes are a legal lead byte inside ordinary UTF-8.

Four templates, not two: default_java_template.sh and .fish guard and read
variables.txt themselves, because they run standalone as well as being sourced
by the start script. Found by grepping for the other readers before calling it
done, which is what the "a fix written against one artifact is a fix for one
artifact" entry in CLAUDE.md asks for.

The comments in those four are written for the operator rather than for us --
Griefed's call. The scripts ship inside every pack and are write-protected but
editable, so whoever opens one has to be able to follow it: what goes wrong in
plain words, then what the line does about it. The mechanism notes behind the
awkward spellings stay, because those are the parts someone would otherwise
tidy back into a bug.

PINS

Each guard was written and run before its fix, and each red was read rather
than assumed. Grouped here, so the message carries what the boundaries would
have:

  RunFileLineEndingsTest: 3 of 4 red on content mismatch, never on a missing
  symbol. Reverting `.withLineEndings(lineEndingFor(name))` to
  `.replace("\r", "")` at either script loop reproduces it. The fourth guard,
  theFilesAPosixShellReadsCarryNoCarriageReturn, was green from the start on
  purpose: it pins the half that was already correct so this cannot regress it.

  VariablesFileLoadingTest: the bash half got a behaviour-preserving seam first
  -- loadVariables() { source "$1"; } plus both call sites switched to it,
  which does exactly what the two `source` lines did -- so the guard fails on
  the logic and not on a missing function. Before each fix, bash failed first
  on crlf, fish only on cr, and exactly the three bom_* cases failed on JAVA
  alone across all four templates. Its fixture puts JAVA first deliberately: a
  variables.txt whose first line is a comment is read correctly by a loader
  that handles nothing at all, so that fixture could not fail.

  Teeth verified by mutation, not by green. Removing the mark strip from all
  four templates names all four; removing it from default_java_template.sh
  alone names that one -- which matters, because the bash guard stops at its
  first failed assertion, so "it went red" does not prove the second template
  is covered.

  One guard had no teeth and was fixed: the fish probe passed against a
  deliberately corrupted installer template, because fish keeps a function
  defined when a later `source` fails, so the first template's working copy was
  silently grading the second's. It now erases the function between templates
  and fails loudly if the source or the definition does not take.

VERIFICATION

api 538 tests green, app 234 green, plugin-servertest 112 green (1 deliberate
skip), no new compiler warnings, fish and PowerShell still parse.

Checked against really generated packs rather than only fixtures -- all five
under serverpackcreator-api/tests/server-packs/ after a suite run: start.bat 38
of 38 lines CRLF; start.sh, start.fish, start.ps1, install_java.sh,
variables.txt and HOW-TO-RUN.md with 0 lines carrying a carriage return; no
staging .tmp left behind; the zips carry the same files.

ON THE REPORT THAT PROMPTED THIS

It does not explain itself. 8.1.0 already stripped carriage returns at all four
write sites and File.writeText does no platform translation, so a variables.txt
8.1.0 wrote was LF-only by construction. Whatever that user's file picked up,
it picked up after generation -- which is the half this commit's reading side
now survives.

Still open: nothing reads start.sh itself if IT arrives with carriage returns.
That fails loudly (`bad interpreter`) rather than quietly, and a script cannot
repair itself before being run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
improv(Modscanning): Skip dependencies which declare an incompatibility
Some checks failed
Documentation and homepage / Version (push) Successful in 30s
Continuous / Preparations (push) Successful in 33s
Documentation and homepage / Homepage: Quasar build (push) Successful in 3m25s
Grinder Container IT / containers (push) Successful in 10m40s
Qodana / scan (push) Successful in 14m8s
Documentation and homepage / Writerside webhelp (push) Successful in 3m53s
Test / Docker image (push) Successful in 12m2s
Documentation and homepage / Dokka HTML (push) Successful in 6m54s
Qodana / notify (push) Successful in 12s
Documentation and homepage / Help image (push) Successful in 3m11s
Continuous / JARs (push) Successful in 10m39s
Continuous / AppImage (aarch64) (push) Successful in 2m31s
Documentation and homepage / Homepage: image (push) Successful in 7m40s
Continuous / AppImage (x86_64) (push) Successful in 2m25s
Continuous / Windows app-image (x86_64) (push) Successful in 7m59s
Continuous / install4j media (push) Successful in 9m26s
Test / build (push) Failing after 42m45s
Continuous / Stage the release assets (push) Successful in 2m13s
Continuous / Build (push) Successful in 0s
Continuous / Continuous Pre-Release (push) Successful in 3m40s
e4844fa5e7
Fixes https://github.com/Griefed/ServerPackCreator/issues/1307

Signed-off-by: Griefed <griefed@griefed.de>
Every Renovate PR on Forgejo failed in `:serverpackcreator-app:bootJar`:

    Entry BOOT-INF/lib/babel-monorepo.jar is a duplicate but no duplicate
    handling strategy has been set.

`test.yml` passes `-Pversion="${{ github.ref_name }}"`, which on a Renovate
branch is `renovate/babel-monorepo`. `version` is a FILE NAME COMPONENT -- every
archive is `<module>-<version>.jar` -- and Gradle does not check that it is a
legal one, so the slash was read as a path separator and the jar landed in
`build/libs/serverpackcreator-api-renovate/babel-monorepo.jar`. Boot copies each
dependency into `BOOT-INF/lib/` under `file.getName()`, which for `-api` and
`-clientside` alike was now `babel-monorepo.jar`. Two jars, one entry name.

Reproduced locally on develop, and the directories are the proof:

    $ ./gradlew :serverpackcreator-app:bootJar -Pversion="renovate/babel-monorepo"
    > Entry BOOT-INF/lib/babel-monorepo.jar is a duplicate ...   BUILD FAILED in 8s
    $ find serverpackcreator-*/build/libs -maxdepth 1 -type d -name '*renovate*'
    serverpackcreator-api/build/libs/serverpackcreator-api-renovate
    serverpackcreator-app/build/libs/serverpackcreator-app-renovate
    serverpackcreator-clientside/build/libs/serverpackcreator-clientside-renovate

That is also why only PR #713 was green: its head is `develop`, every other open
PR is `renovate/<slug>`. `.github/workflows/test.yml` has the identical defect on
every pull request, where `github.ref_name` is `<number>/merge`.

NOT fixed with `duplicatesStrategy` on `bootJar`, which the error message invites:
that is green by dropping one of the two modules out of the shipped fat jar.
Fixed in the build, because the invariant is a build one and the alternative is
the sanitising living in each workflow that passes a ref-derived version, where
the next one written will not have it. `BuildVersion.sanitize` replaces every run
of file-name-illegal characters with a single `-`, and `BuildVersion.of` says so
at lifecycle level, so a rewritten version is auditable from a CI log. It is
applied in `java-conventions`, which every jar-producing module reaches, and in
the root script, which applies no convention plugin and feeds install4j.

A release is untouched: `resolve-version`'s `tag` mode already refuses anything
that is not `X.Y.Z` or `X.Y.Z-(alpha|beta).N`, and `-Pversion=9.3.0` logs nothing
and produces the same names as before.

Measured, per the build-logic standard:

    ./gradlew :serverpackcreator-app:bootJar -Pversion="renovate/babel-monorepo"
      before: FAILED, Entry BOOT-INF/lib/babel-monorepo.jar is a duplicate
      after:  BUILD SUCCESSFUL in 36s
              serverpackcreator-app-renovate-babel-monorepo.jar, and inside it both
              BOOT-INF/lib/serverpackcreator-api-renovate-babel-monorepo.jar
              BOOT-INF/lib/serverpackcreator-clientside-renovate-babel-monorepo.jar
    plugin jars, which the release globs as `*_experimental.jar`:
      before: build/libs/<module>-renovate/babel-monorepo_experimental.jar  (glob misses it)
      after:  build/libs/<module>-renovate-babel-monorepo_experimental.jar  (glob matches)

THE CI HALF, same defect one layer up. `resolve-version`'s `branch` mode ends at
`VERSION="${REF_NAME}-$(git rev-parse --short HEAD)"`, so a slashed ref produced a
slashed version there too. It was latent -- `devbuild.yml` triggers on
`push: develop` plus dispatch -- and the build-side sanitiser only keeps such a run
GREEN, it does not keep the two in AGREEMENT: `inputs.version` labels the release
assets, the installer media, the dokka zip and the `continuous` tag while the build
names the jars inside them. Normalised once after the `case`, covering all four
branch-mode paths and `tag` mode, where it cannot fire. Executed, not reasoned about:

    branch renovate/babel-monorepo  -> renovate-babel-monorepo-444f32dc8  (announced)
    branch develop                  -> develop-444f32dc8                  (untouched, silent)
    override "my/custom ver"        -> my-custom-ver                      (announced)
    tag 9.3.0 / 9.0.0-alpha.6       -> unchanged, prerelease false / true
    tag not-a-version               -> still refuses

PIN FIRST. `BuildVersionTest` was written and run RED against a stubbed `sanitize`
returning its input unchanged -- 4 of 7 failed, and the three that passed were
vacuous against that stub and are named here so they are not mistaken for pins:
`a release version passes through untouched`, `a branch mode version passes through
untouched`, `legal punctuation survives`. The red:

    expected: <renovate-babel-monorepo> but was: <renovate/babel-monorepo>
    expected: <713-merge> but was: <713/merge>
    expected: <feature-windows> but was: <feature\windows>
    expected: <feature-a-b> but was: <feature/ a : b>

The bash and Kotlin normalisations are a FIFTH instance of duplicated knowledge --
the two cannot share a regex -- so the duplication is pinned rather than commented:
`BuildVersionTest` extracts the `sed -E` program OUT OF THE ACTION and runs it over
every case the class asserts, and a cheaper string guard catches a divergence on a
host with no `sed` instead of skipping silently.

TWO WIRING FINDINGS, both of which would have left a guard that never runs.

A buildSrc test source set reverses a recorded decision -- Griefed's call,
2026-10-16 -- and is for PURE FUNCTIONS; task wiring still gets a commit-message
measurement. Gradle 8.0 stopped the main build running buildSrc's lifecycle tasks
(gradle#25439, closed as intended), and measured on 9.8.1 `:buildSrc:check` is
inert, `gradle.includedBuild("buildSrc")` does not resolve, and `-p buildSrc check`
dies in buildSrc's own settings, which declare the foojay resolver with no version
because only the root build puts it on the classpath. The jar IS always built, so
`tasks.jar { finalizedBy(tasks.test) }` is the edge -- `finalizedBy` and not
`dependsOn`, because kotlin-dsl puts that jar on the test compile classpath and
`dependsOn` is a cycle that fails `./gradlew help` outright.

And mutating the action's expression left the suite GREEN, because
`.forgejo/actions/resolve-version/action.yml` was not a declared input of
`:buildSrc:test` -- Gradle reported UP-TO-DATE and the guard could never have fired
on the single edit it exists to catch. With `inputs.file(...)` declared the same
mutation turns both guards red:

    the action and the build disagree about 'feature/ a : b'
      ==> expected: <feature-a-b> but was: <feature--a---b>

Recorded as a landmine, because it applies to any guard reaching across a project
boundary: ask what makes a test re-run, not only what it asserts.

Deferred as B48 -- moving buildSrc to a `build-logic` included build, which would
make root `check` the one honest gate instead of the jar finalizer above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`e4844fa5e` started skipping dependencies declared `type="incompatible"`, which
is right -- an incompatible mod is not a dependency at all. It left develop red
and the new reader has two defects of its own.

THE RED. `OptionalDependencyTest.neoForgeTreatsIncompatibleAndDiscouragedAsNotRequired`
still asserted that an incompatible dependency is PRESENT and optional, which is
the behaviour that commit deliberately replaced:

    expected: <true> but was: <null>

Verified against a clean develop worktree before touching anything: 537 of 538
api tests pass, this one fails. The test is updated rather than the code, because
the skip is the intended behaviour -- renamed to say what it now pins, and it
asserts BOTH halves, since `incompatible` and `discouraged` are no longer the
same answer: the first is dropped, the second survives as optional.

TWO DEFECTS IN `getType`, both pinned red first.

  1. It lowercased but did not trim, where its sibling `isOptional` has always
     done both. A descriptor is hand-written, so ` Incompatible ` occurs, and
     the drop turned on whitespace nobody can see.

         ` Incompatible ` is still incompatible ==>
           expected: <null> but was: <ModDependency(modID='enemy', ..., optional=true)>

  2. It read the value with `as String?`. A `type` holding a number throws
     ClassCastException, which the scan loop does NOT catch -- it catches only
     NullPointerException -- so it escapes `read()` and `DescriptorScanner`
     flattens the jar to the never-read-anything fallback. The mod loses its
     whole descriptor: file name as the mod id, no dependencies, SERVER. That is
     indistinguishable from a jar belonging to another loader, which is why
     nothing would have reported it.

         the descriptor was read, not thrown away ==> expected: <true> but was: <false>

Fixed by giving the `type` field exactly ONE reader. `getType` now trims,
lowercases and uses `?.toString()`, and `isOptional` calls it instead of
repeating the lookup -- the duplication that let the two spellings diverge in the
first place. The duplicate `type` constant goes with it; `dependencyType` already
held "type" and is the documented one. `Optional<String>` becomes a nullable
String, which is what the rest of this module uses.

Six guards in `OptionalDependencyTest`, all green. Full build green: api 540,
app 234, clientside 680, grinder 543 (27 skipped), plugins 3/75/17/112 (1
skipped), buildSrc 7, frontend 38 across 15 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(build): the recorded configuration-cache count is stale, and one of them is ours
All checks were successful
Documentation and homepage / Version (push) Successful in 32s
Continuous / Preparations (push) Successful in 36s
Documentation and homepage / Homepage: Quasar build (push) Successful in 2m42s
Grinder Container IT / containers (push) Successful in 13m5s
Qodana / scan (push) Successful in 16m6s
Documentation and homepage / Writerside webhelp (push) Successful in 2m9s
Documentation and homepage / Dokka HTML (push) Successful in 5m13s
Test / Docker image (push) Successful in 12m3s
Qodana / notify (push) Successful in 12s
Documentation and homepage / Help image (push) Successful in 2m55s
Documentation and homepage / Homepage: image (push) Successful in 4m50s
Continuous / JARs (push) Successful in 11m26s
Continuous / AppImage (aarch64) (push) Successful in 2m10s
Continuous / AppImage (x86_64) (push) Successful in 2m10s
Continuous / Windows app-image (x86_64) (push) Successful in 8m18s
Test / build (push) Successful in 43m6s
Continuous / install4j media (push) Successful in 8m14s
Continuous / Stage the release assets (push) Successful in 1m45s
Continuous / Build (push) Successful in 0s
Continuous / Continuous Pre-Release (push) Successful in 3m50s
4bdd0aa646
`.claude/rules/build-layout.md` recorded "8 problems, 2 of which seem unique, all
of them `:generateLicenseReport`", measured 2026-09-26. The paragraph ends by
telling the reader to re-run the command before believing it, which is what this
is: re-run on Gradle 9.8.1, `./gradlew build --configuration-cache --dry-run`
reports 11 problems, 3 unique.

The third is `:serverpackcreator-app:processResources` holding a `Project`. That
matters more than the count, because the whole point of the recorded number was
that the remaining problems are third-party and unfixable here -- jk1's
gradle-license-report. This one is ours, so the stale entry was not merely out of
date, it was pointing at the wrong ceiling.

Measured against develop in a worktree rather than inferred, because it turned up
while verifying an unrelated branch: develop and that branch both report 11/3, so
nothing on the branch caused it and it arrived somewhere between 2026-09-26 and
today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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!713
No description provided.