You seem lonely #682

Merged
Griefed merged 36 commits from develop into main 2026-10-02 22:36:09 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-09-29 08:54:11 +02:00
`ExtensionTab.log` and `ExtensionConfigPanel.pluginsLog` hand every plugin
`LogManager.getLogger("AddonsLogger")`, and nothing in either log4j2 configuration declared that
name. It inherited Root and wrote `serverpackcreator.log` -- the one file a user is told not to read
when a plugin misbehaves. Measured before the fix, the logger resolved to
[Console, ApplicationLogger].

The logger is ADDED rather than `ExtensionTab`'s being renamed to `PluginsLogger`: the name is baked
into every third-party plugin already compiled against the published API, so a rename would move
their output a second time and break anything filtering on the logger name. Applied to the shipped
configuration and the test copy both, because the test copy shadows the shipped one on the test
classpath and fixing only one leaves the other silently broken in whichever direction was missed.

Caveat recorded in `claude-docs/API-BEHAVIOUR-CHANGES.md` rather than papered over: `ApiProperties`
rewrites the home's `log4j2.xml` only when it is absent or the build is dev/pre-release, so an
existing stable installation keeps its own copy until that file is deleted.

Squashed from 2 commits, 7d057b136..83c34c459, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  test(api): pin that AddonsLogger reaches the plugins appender
  fix(api): configure AddonsLogger so plugin logging reaches plugins.log

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`destroyForcibly()` on the launcher shell left the server it started running -- reparented to init,
still holding the world directory, the port and its heap. SIGKILL cannot be trapped or forwarded, so
the shell could never have cleaned up on our behalf.

`destroyTree` collects the descendants BEFORE killing anything: once the shell dies its children are
reparented and stop being its descendants, so collecting afterwards finds nothing. Everything is
asked to exit first and forced only after one shared five-second budget -- shared rather than
per-process, so a script nesting several shells cannot multiply the teardown wait -- which gives a
server still able to flush its world the chance to do so.

Checked rather than assumed: the shell now exits 143 (SIGTERM) where it exited 137 (SIGKILL), and
`BootLogClassifier.killedExitCodes` holds both, treating neither as a crash. No verdict moves.

Squashed from 2 commits, 7a40e5351..95a1c3031, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  test(clientside): pin that a boot's teardown kills the server, not only bash
  fix(clientside): destroy the boot process's descendants before the process

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing a generated pack meant leaving the application: find the directory, open a terminal,
`bash start.sh`, answer Mojang's EULA, wait out the modloader install, then start a client. Nothing
in the GUI ran a server at all -- a grep for `ProcessBuilder` under
`serverpackcreator-app/**/gui/**` returns nothing. This makes it one click plus connecting a client.

**Launching through the pack's own start script is the requirement, not an implementation choice.**
The scripts install the modloader, pick a Java version, handle the ServerStarterJar and clean up
after a version change; a launcher that built its own `java` command would not be testing what
ships. The plugin gets a GUI tab with a pack list, a console and a line into the server's standard
input, so the EULA prompt the script asks can actually be answered.

`core` holds everything testable without Swing -- platform and start-script selection, pack
discovery, a port allocator that patches and restores `server.properties`, pack-variable warnings,
the session and its registry -- and `gui` holds the one tab plus three pinned non-view units. The
server runs in **its own JVM**, not the application's, so a pack that wedges cannot take
ServerPackCreator with it.

**Zero changes to `serverpackcreator-api`.** Everything the plugin needs was already exported:
`TabExtension`/`ExtensionTab`, `ApiProperties.serverPacksDirectory`, `ServerPackManifest.inside`
and `utilities.jsonUtilities.objectMapper`. That was the constraint set for a feature-complete beta,
and it held. `-clientside` is the tempting dependency and is deliberately not taken: its
`HostProcessServerRunner` spawns `bash start.sh` too, but its contract is the opposite at all three
points that matter -- stdin is `/dev/null`, it writes `eula.txt` itself, and it force-kills the
moment the server is ready.

Squashed from 17 commits, 5b8628e69..e01565eae, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  feat(servertest): module scaffold, plugin identity and tab registration
  test(servertest): pin per-OS start-script selection
  feat(servertest): select the platform's start script
  test(servertest): pin launchable-pack discovery
  feat(servertest): discover launchable server packs
  test(servertest): pin free-port allocation
  feat(servertest): allocate a tested-free port per session
  test(servertest): pin the server.properties port patch and its restore
  feat(servertest): borrow and return the pack's port
  test(servertest): pin session streaming, stdin delivery and teardown
  feat(servertest): run a server pack through its start script
  feat(servertest): settings, pack-variable warnings and the session registry
  feat(servertest): the GUI tab, pack list, console and input line
  chore(servertest): ignore the module's generated runtime home
  feat(servertest): boot a real pack end to end, and say when the script waits
  fix(servertest): wrap the console so its notes are not cut off mid-sentence
  docs(servertest): module CLAUDE.md, refactor-log narrative and two backlog residuals

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addTabExtensionTabs` guards the whole per-extension registration now, not just `addTab`. `name`,
`getTab`, `title`, `icon` and `tooltip` are all third-party code and all four of the others sat
outside the `try`, so a plugin whose tab constructor threw did not lose ITS tab -- the throwable
escaped into `MainPanel`'s initialiser and took the window's entire tab assembly with it, including
every plugin registered after it and ServerPackCreator's own control panel and scanner, which are
added after that call.

Reachable without malice: a plugin compiled against an older `-api` and loaded into a newer one
throws `NoClassDefFoundError` from exactly here, which is why `Error` is caught beside the
exceptions and pinned separately. The failure message reads the extension's `name` through a guard
of its own -- building it eagerly is precisely what put a `name` call outside the `try` in the first
place, and an unguarded read inside the `catch` would turn a reportable failure into a second
escape.

Nothing is added to the published surface: the body lives in an `internal` `addTabExtensionTab`,
extracted in its own behaviour-preserving step so the containment could be pinned without
maintaining a fixture jar that throws on purpose. Row added to
`claude-docs/API-BEHAVIOUR-CHANGES.md` -- an embedder relying on the escape to notice a broken
plugin must read `plugins.log` instead.

Squashed from 3 commits, 00724058a..0e46afa61, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  refactor(api): extract the per-extension tab registration
  test(api): pin that a throwing tab extension cannot take GUI assembly down
  fix(api): contain a misbehaving tab extension instead of losing every tab

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dropdown is populated from `ApiProperties.startScriptTemplates` rather than from a list of its
own. Those keys are what `ServerPackProvisioner` generates packs from -- one per script type,
written out as `start.<key>` -- so reading the same setting is the only thing that stops the plugin
offering a script no generation produces, or hiding one an operator added. `StartScripts.forKey`
must keep agreeing with `ServerPackProvisioner.startScriptName`; disagree and every row reports a
script missing that is sitting right there in the pack. A key the plugin has no interpreter for is
still offered and executed directly, because ServerPackCreator marks generated start scripts
executable and a custom template carrying a shebang runs on its own. The order is imposed, because
`startScriptTemplates` is a `HashMap` and the dropdown otherwise reshuffles between reads.

A `PostGenExtension` refreshes the pack list when a generation finishes, so a pack can be booted
without reopening the tab, and the list keeps the pack the user had selected when it redraws.

Three audit passes are folded in, because they are corrections to code born on this branch rather
than to anything that shipped:

- HTML rendering is actually disabled on all three surfaces that show a pack's name. Two of them
  still rendered it, so a pack directory named with markup was interpreted rather than displayed.
- The launch moved out of the view and into `core.ServerLauncher`, which is what made it testable
  at all; "test(servertest): pin what a launch takes and what it gives back" is the guard.
- Wiring, duplication and an orphaned doc block closed, plus `build.gradle.kts` gaining
  `appPluginConfigurations` so the plugin list is named once.

Squashed from 18 commits, af7dad9e9..d6ae8dbb0, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  docs(audit): record the servertest branch's audit and analysis findings
  fix(servertest): actually disable HTML on the three surfaces that show a pack's name
  test(servertest): pin what a launch takes and what it gives back
  fix(servertest): move the launch out of the view, and close the audit's gaps
  docs(servertest): record the audit dispositions and two guards that lack teeth
  docs: sanction seam-plus-guard as a third way to land a red pin
  docs: correct the app test count from a measured full-suite run
  test(servertest): pin the generation hook that refreshes the pack list
  feat(servertest): refresh the pack list when a server pack is generated
  docs(servertest): record the generation hook and the JVM separation
  test(servertest): pin that the user chooses which start script runs
  feat(servertest): let the user choose which start script runs
  docs(servertest): record that the user picks the start script
  test(servertest): pin that the offered scripts come from SPC's templates
  feat(servertest): offer the start scripts SPC is configured to generate
  docs(servertest): record that the offered scripts follow SPC's templates
  test(servertest): pin that redrawing the list keeps the pack selected
  fix(servertest): keep the selected pack selected when the list redraws

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`./gradlew buildPlugins` goes from source to a ServerPackCreator with the plugins installed: every
plugin module built with its tests, then the jars refreshed in the app's manual-test plugins
directory.

It is not an alias for `copyPluginsToApp`. That task builds the jars it copies -- the
`pluginArtifact` configurations carry the dependency -- but runs none of the plugins' tests:
measured at 0 plugin `:test` tasks across 45 executed tasks while staging every jar. A jar staged
that way is unverified. The `mustRunAfter` is load-bearing rather than defensive, and the mutation
says so: remove it and `copyPluginsToApp` runs FIRST, staging the jars and only then testing them,
which is exactly backwards. It adds ordering and no dependency, so `copyPluginsToApp` invoked alone
is unchanged.

The plugin list is not spelled twice. `appPluginConfigurations` is read by both the copy and the
build, so a plugin added there is staged *and* built -- an audit caught the first version listing
the configurations a second time, which left precisely the drift the comment claimed to prevent.
`ProjectDependency.path` rather than `dependencyProject`, because a path is a lazy string while
reaching for the project object is the cross-project access this build spent a sprint removing.

Squashed from 2 commits, 35a93c00d..9c3dee482, 2026-09-25. Narrative: claude-docs/REFACTOR-LOG.md

  build: add buildPlugins, which builds every plugin then stages them
  fix(servertest): close the third audit pass — wiring, duplication, orphaned doc

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isStillRunning` now asks whether the process is running, not whether its PID is still in the
table: alive AND still carrying a command line. The kernel drops the command line on exit while the
PID lingers, and every handle teardown sees belongs to a process this JVM started under the same
user, so it is readable wherever it matters.

The graceful wait polls that predicate instead of awaiting `onExit`. `onExit` never completes for a
process nobody will reap, so a single unreaped descendant spent the whole `GRACEFUL_TEARDOWN_SECONDS`
budget waiting for an exit that had already happened, then force-killed a zombie for nothing.

Measured in a container whose PID 1 is `tail -f /dev/null` -- the shape of the CI job container --
same program before and after:

    before: onExit -> TimeoutException, teardown 5000+ ms, child read as a survivor (build red)
    after:  teardown 3 ms, child correctly read as stopped

No verdict moves: `BootLogClassifier` never saw these handles, only the shell's exit code, which is
unchanged.

Squashed from 4 commits, 2bc63c48e..a6340bd19, 2026-09-26. Narrative: claude-docs/REFACTOR-LOG.md

  refactor(clientside): name the liveness test destroyTree makes
  test(clientside): pin that a descendant awaiting reaping is not still running
  fix(clientside): stop counting an unreaped descendant as a live process
  docs(clientside): record that "alive" is not "running" for a boot's processes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two artifacts per generated pack: a `.bsx` for Linux and macOS, a `.cmd` for Windows 10 1803+. It is
the `HELP.md` chapter *Fun Stuff -> Self-extracting, self-contained script* executed automatically,
and the two must stay in step. One extension point, no tab and no configuration -- installing the
plugin is the setting and removing it is how you turn it off, which is one fewer place for the
answer to live than a checkbox that can disagree with what the plugin does.

**The TAR format is written here by hand, and that is a delivery constraint rather than a
preference.** A pf4j plugin jar carries no dependencies, nothing in this repository builds a fat
plugin jar, and the host's runtime classpath has no TAR writer on it -- so `commons-compress` could
not reach the machine this runs on. What makes that safe is that nothing in `TarGzWriterTest` judges
the output with a reader of our own: every assertion is the verdict of the machine's own `tar`.
Measured against a real generated pack (`serverpackcreator-api/tests/server-packs/forge_tests`): 44
files out, `start.sh` `-rwxr-xr-x`, no warnings, under bsdtar (macOS), GNU tar (debian) and busybox
tar (alpine).

Two landmines are recorded in the module's own `CLAUDE.md` because they look like tidiable
awkwardness and are not. A zero-padded fixed-width payload offset breaks BSD `tail`, which is what
macOS ships (`illegal offset -- +000000000639`), so `SelfExtractingArchive.settleOffset` iterates
instead -- it settles in one or two rounds, because a round can only add digits. And the batch
stub's rules are not style: no `goto` and no label, because `cmd.exe` resolves a label by scanning
the file and gzip output is full of null bytes it cannot scan across.

The hook refuses what it cannot wrap and never throws, so a pack that cannot be self-extracted is
still a pack that generated.

Squashed from 58 commits, c327ff109..983519bfa, 2026-09-25 .. 2026-09-27. Narrative: claude-docs/REFACTOR-LOG.md

  test(api): pin that AddonsLogger reaches the plugins appender
  fix(api): configure AddonsLogger so plugin logging reaches plugins.log
  test(clientside): pin that a boot's teardown kills the server, not only bash
  fix(clientside): destroy the boot process's descendants before the process
  feat(servertest): module scaffold, plugin identity and tab registration
  test(servertest): pin per-OS start-script selection
  feat(servertest): select the platform's start script
  test(servertest): pin launchable-pack discovery
  feat(servertest): discover launchable server packs
  test(servertest): pin free-port allocation
  feat(servertest): allocate a tested-free port per session
  test(servertest): pin the server.properties port patch and its restore
  feat(servertest): borrow and return the pack's port
  test(servertest): pin session streaming, stdin delivery and teardown
  feat(servertest): run a server pack through its start script
  feat(servertest): settings, pack-variable warnings and the session registry
  feat(servertest): the GUI tab, pack list, console and input line
  chore(servertest): ignore the module's generated runtime home
  feat(servertest): boot a real pack end to end, and say when the script waits
  fix(servertest): wrap the console so its notes are not cut off mid-sentence
  docs(servertest): module CLAUDE.md, refactor-log narrative and two backlog residuals
  refactor(api): extract the per-extension tab registration
  test(api): pin that a throwing tab extension cannot take GUI assembly down
  fix(api): contain a misbehaving tab extension instead of losing every tab
  docs(audit): record the servertest branch's audit and analysis findings
  fix(servertest): actually disable HTML on the three surfaces that show a pack's name
  test(servertest): pin what a launch takes and what it gives back
  fix(servertest): move the launch out of the view, and close the audit's gaps
  docs(servertest): record the audit dispositions and two guards that lack teeth
  docs: sanction seam-plus-guard as a third way to land a red pin
  docs: correct the app test count from a measured full-suite run
  test(servertest): pin the generation hook that refreshes the pack list
  feat(servertest): refresh the pack list when a server pack is generated
  docs(servertest): record the generation hook and the JVM separation
  test(servertest): pin that the user chooses which start script runs
  feat(servertest): let the user choose which start script runs
  docs(servertest): record that the user picks the start script
  test(servertest): pin that the offered scripts come from SPC's templates
  feat(servertest): offer the start scripts SPC is configured to generate
  docs(servertest): record that the offered scripts follow SPC's templates
  test(servertest): pin that redrawing the list keeps the pack selected
  fix(servertest): keep the selected pack selected when the list redraws
  build: add buildPlugins, which builds every plugin then stages them
  fix(servertest): close the third audit pass — wiring, duplication, orphaned doc
  refactor(clientside): name the liveness test destroyTree makes
  test(clientside): pin that a descendant awaiting reaping is not still running
  fix(clientside): stop counting an unreaped descendant as a live process
  docs(clientside): record that "alive" is not "running" for a boot's processes
  feat(selfextract): a plugin module that wraps every server pack in a self-extractor
  test(selfextract): pin the TAR writer against the real tar, not against a reader of our own
  feat(selfextract): write the TAR format, and prove it against three implementations
  test(selfextract): pin both artifacts - the offset, the stub rules, and a pack that starts
  feat(selfextract): assemble both artifacts, with the payload's position settled into each stub
  test(selfextract): ask a real PowerShell whether the batch stub is a program at all
  test(selfextract): pin the hook, including the two ways a pack can be unwrappable
  feat(selfextract): wrap the finished pack, and survive the packs that cannot be wrapped
  docs(selfextract): record why the module writes TAR itself and what must not be tidied
  build(selfextract): ignore the module's test home, like every other module's

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every forked test JVM was free to initialise AWT, and on macOS a JVM that does becomes a windowed
application: a Dock icon, and the keyboard focus taken from whatever was being typed in. This build
forks one test JVM per module, so a full run did it eight times. Nothing in the build set
`java.awt.headless` -- the property appears nowhere outside three test resource files' help text --
so this is the first time it is set at all.

Measured rather than assumed, in both directions:

    before   no java.awt.headless anywhere; a macOS JVM with a display defaults to false
    after    [probe] java.awt.headless=true isHeadless=true   (inside a forked test JVM)

and the whole suite re-run from scratch with `test --rerun-tasks`: 2153 tests, 25 skipped, 0 failed,
no `HeadlessException` anywhere. That was the risk worth checking -- eleven test classes construct
Swing components -- and constructing a renderer, a `JTextArea` or a table model is allowed headless.
Only something that genuinely needs a screen would now fail, which is an improvement over silently
depending on the developer having one.

The test-count table in `CLAUDE.md` is re-derived from that run.

Squashed from 1 commits, 45863f131, 2026-09-27. Narrative: claude-docs/REFACTOR-LOG.md

  build: run test JVMs headless, so a suite stops stealing the developer's focus

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plugin jar is now `<module>-<version>_experimental.jar` — locally, in the dev build and in the
release alike, because the name is set where the jar is built rather than where it is published.
That is the arrangement `misc/build-appimage.sh` already has for the AppImages and it exists for the
same reason: these four plugins are far newer and less travelled than the application they plug into,
and a user picking a download should be able to see that from the filename alone.

`serverpackcreator.plugin-conventions` is the one place that decides it. Naming it in each of the four
modules would have been four copies of one decision, which is the shape this repository keeps paying
for; naming it in the release workflow instead would have given the same artifact two names, plain
where a developer sees it and flagged where a user does.

The convention plugin also takes over the `pluginArtifact` configuration and its `artifacts { add }`,
which stood verbatim in all four module build scripts. The per-plugin manifest stays where it is —
a plugin's id, class and description are data, not a shared choice.

Computed eagerly rather than through a provider that reads `project`: this build runs with the
configuration cache, which cannot serialize a captured `Project`. `version` comes from
`gradle.properties` (`dev`) or `-Pversion=<release>`, so it is never blank.

Measured, `./gradlew buildPlugins` from a clean state on the four modules:

    before   serverpackcreator-plugin-{example,grinder,selfextract,servertest}-dev.jar
    after    serverpackcreator-plugin-{example,grinder,selfextract,servertest}-dev_experimental.jar

BUILD SUCCESSFUL in 35s, all four `:test` tasks executed, exactly one jar per module, and all four
staged into `serverpackcreator-app/tests/plugins`. Nothing reads a plugin jar by name — a repository
grep finds the string only in `build.gradle.kts`'s project paths and in workflow globs — so the rename
reaches no test and no runtime lookup.

BUILD.md gains the naming rule and stops saying "three jars", which it has been since the fourth
plugin landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Collect files` copied `serverpackcreator-plugin-example/build/libs/*.jar` and nothing else, so the
grinder, server-test and self-extract plugins were built by the release job, tested by it, and then
left in the workspace. Users had no way to get them short of building the project.

Both workflows now glob `serverpackcreator-plugin-*/build/libs/*_experimental.jar`. A list of module
names here would have been the third one — `settings.gradle.kts` and the root build's
`appPluginConfigurations` already hold the other two — and the third list is the one that silently
stops growing, which is precisely what the old line did while three more plugins appeared beside it.

The count is derived rather than written down, so it cannot go stale either: one jar per plugin module
directory, no more and no fewer. Fewer means a module whose jar did not build or did not get the name;
more means a stale jar from an earlier build, which a fresh release workspace should not have and which
would otherwise ship twice. Exercised both ways before committing — 4 modules / 4 jars passes, and
hiding one jar makes it fail with the module count in the message. The jars land in `release/$V`
before `checksum.txt` is generated, so they are covered by it like every other asset.

The dev build gets the same glob, because a `continuous` build carrying one of four plugins is the
divergence this repository keeps writing rules about. The AppImage jobs are unaffected:
`misc/build-appimage.sh` looks only in `serverpackcreator-app/build/libs`.

One visible change for anyone with a bookmarked URL: the example plugin's asset is now
`serverpackcreator-plugin-example-<version>_experimental.jar` rather than
`serverpackcreator-plugin-example-<version>.jar`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Upload test results` named each module's XML path one by one, so it stopped at `-plugin-example` and
`-plugin-grinder` while `-plugin-servertest` and `-plugin-selfextract` were added beside them. Their
tests ran on every push — `./gradlew build` reaches them — and 128 results (111 + 17) simply never
reached the artifact, which is the one thing anybody downloads after a red run.

Globbed rather than extended by two more lines, so the next plugin module reports itself the day it
lands.

`build-artifacts` below is deliberately left naming `-plugin-example` alone: that upload is whole build
trees, already 5708 files on the run that surfaced the GHES refusal, and it is a convenience copy of
something any checkout can rebuild. Three more of them would be paid for on every push to save a
download nobody makes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ApiPluginsTest` loads whatever jar sits in `src/test/resources/testresources/plugins`, so the fixture
is filename-agnostic and nothing was broken. What would have bitten is `copyPluginsApiUnitTests`, which
deletes every jar there and copies a fresh one in: the next person to run it would have got an
unexplained delete-plus-add in `git status`, for a fixture that is checked in on purpose.

Renamed rather than rebuilt. The bytes are the known-good fixture that has been loading through pf4j
all along; regenerating it would have swapped the fixture as well as its name, for no gain.

Verified: `./gradlew :serverpackcreator-api:test --tests '*ApiPluginsTest*'` BUILD SUCCESSFUL in 19s
(a filter matching nothing fails the task, so this is the test running, not being skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record where a plugin jar's name is decided, and stop counting plugin modules
Some checks failed
Grinder Container IT / containers (push) Successful in 9m25s
Docker Test / build image (push) Successful in 12m0s
Qodana / scan (push) Successful in 15m52s
Test / build (push) Failing after 36m57s
Qodana / notify (push) Successful in 15s
4a42b16f16
`.claude/rules/build-layout.md` gains the `serverpackcreator.plugin-conventions` entry: what it owns,
why the name is computed eagerly rather than through a provider that captures `Project`, why the
decision must not move into a workflow, and the grep that established nothing reads a plugin jar by
name. That file is the "do not tidy that away" record for build declarations, and a single-line
convention plugin whose whole point is being the only copy of something is exactly what gets tidied.

CLAUDE.md said "all three plugin modules" while four exist — it was bumped from "both" when
`-plugin-servertest` landed and not again when `-plugin-selfextract` did, three commits later. Replaced
with "every plugin module", which cannot go stale the next time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docker-test.yml has now lost three runs to the pull of
ghcr.io/linuxserver/baseimage-ubuntu:noble -- 676 and 680 on 2026-09-27,
739 on 2026-09-28:

  ERROR: failed to copy: httpReadSeeker: failed open: unexpected status
  from GET request to https://ghcr.io/v2/linuxserver/baseimage-ubuntu/
  blobs/sha256:5d9a14c0...: 429 Too Many Requests
  toomanyrequests: retry-after: 933.17us, allowed: 44000/minute

676 died resolving the manifest, 680 and 739 fetching a blob, so the
exposure is every request to the registry rather than one of them.

The login step added for the first two was the wrong fix, and run 739 is
the proof: it logged `Login Succeeded` and `Authenticated to ghcr.io as
***`, buildkit emitted its `[auth] ... token for ghcr.io` vertex (absent
from job 1712, which had no credentials), and the blob GET still came
back 429. 44000/minute is not an allowance one build can exhaust and the
retry-after is 933 microseconds: this is a token bucket keyed on the
requesting host, refilling in under a millisecond, so an account-scoped
credential is the wrong axis. The login stays -- it costs a second and is
right for Docker Hub's real per-account quota -- with its comment
corrected to stop claiming it fixes this.

What fixes it is waiting, which buildkit does not do. The only retry a
`uses:` step admits is `continue-on-error` plus a second copy of it;
nick-fields/retry and its kin run shell commands, not JavaScript actions.
Verified against the runner's engine rather than assumed:
pkg/runner/step.go in code.forgejo.org/forgejo/act sets stepResult.Outcome
to failure while isContinueOnError turns Conclusion back to success, and
StepResult marshals `outcome` as exactly the string the `if:` compares
against.

release-build.yml gets the same treatment and is the one that matters: a
429 there costs the release its images and takes the `mirror` job
(needs: docker) with it. Its retry is expensive by construction --
`no-cache: true` means attempt two rebuilds the whole Gradle stage, about
fifteen minutes -- but pushing the same tags twice is idempotent, so the
cost of a needless retry is time and the cost of not having one is a
release with no image. docker-test's retry is nearly free, because the
builder container outlives the step and buildkit reuses what it fetched.

One retry, not a loop: a host still limited a minute later is a condition
worth failing loudly on.

Not removed by this: every job on the instance pulls from ghcr, and
`concurrency` is per-ref, so one push routinely runs two builds of the
same commit against one registry from one address. A pull-through cache
on the runner host is the fix for the cause; it is host configuration,
not a repository change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Companion to the 429 retry: that absorbed the symptom, this removes the
cause. Host configuration rather than a repository change, so the
workflow half of it is written out but deliberately not applied -- a
`driver-opts: network=registry-cache` pointing at a network that does not
exist yet would break every image build.

Four things in it are not guessable from the error and cost real time to
establish:

- Docker Engine's `registry-mirrors` mirrors Docker Hub and nothing else,
  so the consumer that actually fails (buildkit, pulling
  ghcr.io/linuxserver/baseimage-ubuntu) and the consumer people reach for
  first (dockerd) need different answers. Upstream: "it's currently not
  possible to mirror another private registry".
- BuildKit falls back to the upstream registry when a mirror fails, which
  makes the change safe to land -- but the buildkitd reference does not
  say so. Read out of NewRegistryConfig in util/resolver/resolver.go,
  which appends each mirror first and the upstream host last, and gives
  mirrors Pull|Resolve only, so pushes always go upstream and
  release-build's push is untouched.
- A mirror on plain HTTP needs its own top-level
  `[registry."<host:port>"] http = true` stanza. newMirrorRegistryHost
  defaults a mirror to https and fillInsecureOpts looks the scheme up
  under that separate section; omit it and buildkit fails the handshake
  and silently falls back, i.e. the mirror does nothing.
- The buildx builder is a container, so it must join the cache's network
  rather than dial a published port on the host. This host's INPUT chain
  is `policy DROP`, which is the exact configuration that cost this
  project a 131-second outage diagnosed three wrong ways, and it fails
  the same silent way here.

Also records the two free checks worth doing first: the runner's
`container.force_pull` (defaults false, but most compose examples for
forgejo-runner set it true, which is one ghcr pull per job), and that
per-ref `concurrency` doubles every pull on a push to develop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"`concurrency` is keyed per-ref, so one push builds the same commit twice"
states the enabling condition as if it were the cause. Three things have
to coincide: a workflow triggering on both `push:` and `pull_request:`, an
open PR whose head is the pushed branch, and a group carrying
`${{ github.ref }}` so the two refs land in different groups. Drop any one
and the doubling stops.

It also over-generalised to the whole directory. Checked against all nine
workflows: docker-test.yml and test.yml meet all three and run in
parallel; grinder-container-it.yml fires twice but its group carries no
ref, so the second queues; docs.yml, qodana.yml, devbuild.yml and both
release workflows trigger on push only and never double.

Evidence named rather than asserted: run indices 665 (#678) and 666
(develop), same commit, both started 2026-09-27T15:10:11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci: Use registry cache to reduce hub pulls
Some checks failed
Continuous / Build JAR (push) Failing after 10m13s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (push) Failing after 40s
Documentation / Writerside webhelp (push) Successful in 1m12s
Grinder Container IT / containers (push) Successful in 8m58s
Docker Test / build image (pull_request) Failing after 57s
Grinder Container IT / containers (pull_request) Successful in 4m36s
Qodana / scan (push) Successful in 11m17s
Documentation / Help image (push) Successful in 2m58s
Qodana / notify (push) Successful in 15s
Test / build (push) Failing after 29m15s
Test / build (pull_request) Failing after 20m28s
96d7e2c71e
Signed-off-by: Griefed <griefed@griefed.de>
docs: Update branding for OS license sponsores
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m40s
Grinder Container IT / containers (push) Successful in 6m1s
Qodana / scan (push) Successful in 12m47s
Grinder Container IT / containers (pull_request) Successful in 6m36s
Documentation / Help image (push) Successful in 2m26s
Qodana / notify (push) Successful in 15s
Docker Test / build image (push) Failing after 1m21s
Continuous / Build JAR (push) Failing after 14m55s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (pull_request) Has been cancelled
Test / build (push) Has been cancelled
Test / build (pull_request) Has been cancelled
62361f8e62
Signed-off-by: Griefed <griefed@griefed.de>
ci: Try with runners_default
Some checks failed
Docker Test / build image (push) Failing after 1m33s
Documentation / Writerside webhelp (push) Successful in 2m20s
Grinder Container IT / containers (push) Successful in 5m20s
Continuous / Build JAR (push) Failing after 10m2s
Docker Test / build image (pull_request) Failing after 39s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Grinder Container IT / containers (pull_request) Successful in 7m1s
Qodana / scan (push) Successful in 13m18s
Documentation / Help image (push) Successful in 3m0s
Qodana / notify (push) Successful in 9s
Test / build (push) Has been cancelled
Test / build (pull_request) Has been cancelled
5f7f43acfc
Signed-off-by: Griefed <griefed@griefed.de>
ci: Try and use conditional driver_opts
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m34s
Grinder Container IT / containers (push) Successful in 6m16s
Continuous / Build JAR (push) Failing after 11m3s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (push) Successful in 14m1s
Grinder Container IT / containers (pull_request) Successful in 6m20s
Docker Test / build image (pull_request) Successful in 13m57s
Qodana / scan (push) Successful in 15m33s
Documentation / Help image (push) Successful in 3m1s
Qodana / notify (push) Successful in 14s
Test / build (pull_request) Failing after 23m19s
Test / build (push) Failing after 26m15s
6a58d32c46
Signed-off-by: Griefed <griefed@griefed.de>
Writerside cannot host a generated file tree, so the Dokka HTML and the webhelp
are merged at packaging time instead of inside Writerside. `cfg/static` takes
individually *referenced* files only (build-profiles.xsd), and JetBrains' own
WRS-1772 "Integration with Dokka" is still open, so there is no supported way to
hand Writerside a directory. `docker/Dockerfile.help` therefore copies the tree
to `/config/www/api/` after the unzip, and `spch.tree` gains a TOC entry pointing
at it.

The TOC href is RELATIVE (`api/index.html`) rather than root-absolute. Every
Writerside topic is emitted at the SITE ROOT despite `web-path="topics"` --
verified by extracting the published `serverpackcreator-help:latest` image, where
`index.html` and `api-reference.html` sit directly in `/config/www` -- so one
relative link resolves both under nginx at `/api/` and under GitHub Pages at
`/ServerPackCreator/api/`, which is a project subpath. An absolute `/api/` would
have worked in the container and 404'd on Pages.

Dokka tolerates the nesting: its HTML has no root-absolute asset references at
all and uses `../../../../`-style paths. Served two levels deep, every asset on
the deepest page (`-api-wrapper/-companion/index.html`) returned 200.

Verified by building the image with the real 1411-file tree and running it:
`/`, `/api/`, `/api/index.html`, the deepest page, `styles/main.css` and
`ui-kit.min.js` all 200. With `public/api` absent the build fails loudly
("failed to compute cache key: /public/api: not found"), which is the intent --
a missing reference must not ship as a silent 404 behind the TOC entry.

The artifact crosses between jobs as a single tar.gz rather than 1411 files.
Measured on this tree: 1411 files / 20,288 KiB raw -> 1,308 KiB as tar.gz, and
4,376 KiB as zip, because gzip compresses across members where zip compresses
each separately. tar also avoids `unzip`, which appears nowhere else in
`.forgejo/workflows` and which `Dockerfile.help` has to `apk add` for itself.
Pack/unpack round-trip verified byte-identical at 1411 files.

Note `dokkaGenerateHtml` WIPES `build/dokka` before writing, so the Javadoc
publication's `build/dokka/javadoc` does not survive beside it -- which is what
makes uploading the whole directory safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(ci): publish the help site and its API reference to GitHub Pages
Some checks failed
Grinder Container IT / containers (push) Successful in 8m51s
Qodana / scan (push) Successful in 11m52s
Qodana / notify (push) Successful in 25s
Docker Test / build image (push) Failing after 2m47s
Test / build (push) Failing after 28m50s
6f080f8c71
Restores what `8.x.x`'s `.github/workflows/github_release.yml` `pages` job did,
and extends it: Pages now serves the whole help site with Dokka nested at
`/api/`, the same layout `docker/Dockerfile.help` produces, rather than Dokka
alone. Hosting the rendered site stopped being possible when GitLab Pages went
away; Forgejo has no equivalent, and the webhelp has since existed only as a
container image.

This is the rare thing that belongs in `.github/workflows` rather than
`.forgejo/workflows`. The landmine is unchanged -- `.forgejo/workflows` existing
means Forgejo ignores `.github/workflows` entirely -- so the criterion is "GitHub
is the only place this CAN happen", which Pages meets by definition. It takes no
secrets and gates nothing, so a red run costs a stale docs site and nothing else.
The rule file states that criterion now, because `pages.yml` stretches it the
furthest of anything there and the next reader will otherwise re-litigate it.

Three deliberate differences from the job it replaces:

  1. Dokka moves from the Pages root to `/api/`. Deep bookmarks to
     `griefed.github.io/ServerPackCreator/-server-pack-creator/...` will 404.
     That is the price of both deployments having ONE layout, which is what lets
     a single relative link in `spch.tree` serve each of them. Griefed's call.
  2. Deploys via `actions/deploy-pages` instead of committing to `gh-pages`.
     REQUIRES Settings -> Pages -> Source = "GitHub Actions"; while it is still
     "Deploy from a branch" this workflow goes green and publishes nothing.
  3. `-Pversion` is actually set on a tag. The old job read
     `needs.preparations.outputs.version` without declaring `needs: preparations`,
     and the `needs` context carries only declared jobs -- so it expanded to the
     empty string and every published page was built as `-Pversion=""`.

The `Assemble the site` guards are load-bearing, not decoration. Read from the
action's `index.js` at the pinned sha: it runs `helpbuilderinspect ... || true`
and then fails only when the artifacts directory is entirely EMPTY. A build that
dies after emitting just `report.json` reports success, so without `test -s` the
site would publish with no help in it.

Two things must move together with `docs.yml`, and both are commented: the
`INSTANCE`/`ARTIFACT` pair and the *Stage documents and images* step, since the
seven root documents are generated into `Writerside/topics/` and a fresh checkout
has none of them. The pinned builder version is a third, with a trap -- the two
pull the SAME tag from DIFFERENT registries. `docs.yml` runs
`jetbrains/writerside-builder` from Docker Hub by hand; this action hard-codes
`registry.jetbrains.team/p/writerside/builder/writerside-builder`. Confirmed via
`docker manifest inspect` that 2026.02.8644 resolves in the JetBrains registry,
so a valid Docker Hub tag is not automatically valid here.

NOT verified: that the builder emits the relative TOC href verbatim. The builder
image is amd64-only and OOM-killed under emulation locally (1.385 GiB against a
1.925 GiB Docker ceiling). Griefed's call to let CI answer it -- the blast radius
is the sidebar entry alone, since the served layout is independently verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure seam, introduced carrying the behaviour it already had (`handle.isAlive`)
so that the guard in the next commit fails on the LOGIC rather than on a missing
symbol -- a guard that cannot compile is not a red pin. The predicate is wrong;
this commit deliberately does not fix it.

Only the descendant call site moves. `spawned` is this JVM's own child, which the
JVM reaps, so `Process.isAlive` is accurate there and those checks stay as they
are. The descendants are the shell's children, reparented on its death, and they
are the ones whose liveness the JVM cannot vouch for.

`:serverpackcreator-plugin-servertest:test` green before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RED, deliberately, against the seam the previous commit introduced. Run before
committing, and the red is the missing implementation rather than the guard's own
fault:

  SessionLivenessTest > aDescendantAwaitingReapingIsNotStillRunning FAILED
    Process 48213 has exited and is only awaiting reaping, but teardown still
    counts it as running. ==> expected: <false> but was: <true>

Three things about that run make it a pin rather than a red light. The FIRST
assertion -- a genuinely running process counts as running -- passed, so the guard
has teeth in both directions and cannot be satisfied by a predicate that returns a
constant. The `assumeTrue` did not skip, so the zombie really was staged. And the
failure is on the second assertion's logic, not on a missing symbol or a fixture
that could never have resolved.

Worth recording: the trap stages fine on macOS with the exec-keeper technique, so
this defect was never "CI-only". What CI has that a developer machine lacks is a
PID 1 that never reaps, which makes the zombie PERMANENT rather than momentary --
so the same wrong predicate is a flake locally and a hard failure there. That is
why `SessionRegistryTest.killAllEndsTheServersAndWhatTheySpawned` could sit green
for so long.

Mirrors `HostProcessLivenessTest` in `-clientside`, which pinned the identical
distinction after CI run 629. The duplication is deliberate and argued in the
class doc: `-plugin-servertest` depends on `-api` only, by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(servertest): ask whether a descendant is running, not whether its PID exists
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m14s
Documentation / Dokka HTML (push) Successful in 4m53s
Continuous / Build JAR (push) Successful in 10m8s
Docker Test / build image (push) Successful in 13m8s
Grinder Container IT / containers (push) Successful in 7m40s
Docker Test / build image (pull_request) Successful in 14m8s
Grinder Container IT / containers (pull_request) Successful in 6m49s
Qodana / scan (push) Successful in 14m17s
Documentation / Help image (push) Successful in 3m37s
Test / build (pull_request) Successful in 12m7s
Continuous / Build AppImage (x86_64) (push) Successful in 2m35s
Continuous / Build AppImage (aarch64) (push) Successful in 2m55s
Qodana / notify (push) Successful in 9s
Test / build (push) Successful in 13m57s
Continuous / Build Install4J Media (push) Successful in 9m10s
Continuous / Continuous Pre-Release (push) Successful in 3m48s
716fb01e18
Turns the previous commit's pin green, and fixes the CI failure it describes:

  SessionRegistryTest > killAllEndsTheServersAndWhatTheySpawned FAILED
    Process 1986 outlived ServerPackCreator's shutdown. ==> expected: <false>
    but was: <true>

`isStillRunning` now reads `handle.isAlive && handle.info().command().isPresent`.
The kernel drops a process's command line on exit while its PID lingers, so that
conjunction is what separates "running" from "in the table awaiting reaping".

THREE call sites were asking the wrong question, not one. The reported failure is
`SessionRegistryTest`; `ServerSessionTest`'s survivor check had the identical bug
and had simply not been unlucky yet.

The one that matters most was not a test at all. `stop()` waited on descendants
with `descendant.onExit().get(remaining)`, and that future NEVER completes for a
process nobody will reap -- so under a container PID 1 that is not an init, every
stop burned the whole shared GRACEFUL_TEARDOWN_SECONDS budget waiting on processes
that had already exited, then force-killed the corpses. It now polls
`isStillRunning` instead. `-clientside` measured the equivalent change at 5000+ ms
-> 3 ms; this one is not independently measured, because staging it needs the
container PID 1, and that measurement is recorded rather than re-derived.

`spawned` is deliberately left on `Process.isAlive`. It is this JVM's own child,
the JVM reaps it, and the two questions cannot diverge there.

:serverpackcreator-plugin-servertest:test -- 112 tests, 0 failures, 1 skip (the
deliberate real-boot skip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci: Use actual network name
Some checks failed
Continuous / Build JAR (push) Successful in 16m38s
Grinder Container IT / containers (pull_request) Failing after 18m7s
Docker Test / build image (pull_request) Successful in 41m36s
Docker Test / build image (push) Successful in 41m37s
Documentation / Writerside webhelp (push) Successful in 7m50s
Documentation / Dokka HTML (push) Successful in 8m17s
Test / build (pull_request) Successful in 18m13s
Grinder Container IT / containers (push) Failing after 23m18s
Continuous / Build AppImage (x86_64) (push) Successful in 3m51s
Test / build (push) Failing after 13m37s
Continuous / Build AppImage (aarch64) (push) Successful in 6m20s
Qodana / scan (push) Successful in 27m24s
Qodana / notify (push) Successful in 2m17s
Continuous / Build Install4J Media (push) Successful in 14m29s
Continuous / Continuous Pre-Release (push) Successful in 8m26s
Documentation / Help image (push) Successful in 21m57s
921d469382
Signed-off-by: Griefed <griefed@griefed.de>
Forgejo run 836 failed all fourteen database-backed tests -- six in
WebServiceContextTest, eight in WebPersistenceIT -- on one cause:

  Could not start process:
  Hmm.. no failure or success message after 30000ms ..
  {"c":"STORAGE","id":22315,"ctx":"initandlisten","msg":"Opening WiredTiger"}

and nothing after that line. 30000ms is flapdoodle's own default
(ProcessDefaults.startTimeout), not a number anything in this repository chose.

The same host had started the same mongod minutes earlier: EmbeddedMongoAvailable's
probe reached "Opening WiredTiger" at 16:12:38.114 and emitted its next line
**14.6 s** later. The context's mongod opened WiredTiger at 16:13:12.166 and produced
nothing inside the budget. Nothing was wrong with mongod, the context or the tests;
the deadline was shorter than a starved host's disk.

Starved is the operative word, and it is measured rather than assumed. That push
queued nine runs at 17:03:00 and the runner ran three Gradle/npm builds at once for
the next 41 minutes, against a staggered schedule the day before:

  docker-test.yml          13-15 min  ->  41.6 min   (829, 830)
  devbuild.yml             ~20 min    ->  96.6 min   (828)
  docs.yml                 ~25-31 min ->  63.4 min   (832)
  grinder-container-it.yml 5.3-8.8min ->  18.1, 23.3 min (831, 834, both red)

Inside docker-test's own buildkit graph the Gradle stage went 616s -> 1913s and the
npm stage 447s -> 817s -- CPU-bound work, no registry involved.

So: three minutes, on both sides. The probe gets it too, because a probe that is more
patient than the thing it vouches for answers "mongod starts here" and then watches
the context fail on the start it just approved -- which is exactly how a guard written
to turn this into a SKIP let it through as fourteen failures instead.

Verified the property is consumed rather than silently ignored, this module having
already paid for that twice with spring.data.mongodb.uri: set to 1, every test in both
classes fails with flapdoodle's own "no failure or success message after 1ms". Restored
to 180000, both classes are green locally (BUILD SUCCESSFUL in 15s). Also checked, and
it corrected a claim this commit nearly shipped: the hyphenated `start-timeout` binds
too, so the one-word spelling is chosen because it is what EmbeddedMongoProperties
declares, not because the other one fails.

NOT changed, deliberately: the WiredTiger cache, which mongod sized at 63846M from the
host's RAM. It looks like the culprit and there is no measurement saying it is -- WT
allocates the cache lazily, so capping it would be an optimisation chosen before
measuring, which is the thing this repository's conventions warn about by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PowerShellInstallerJavaTest failed on GitHub's test workflow on 2026-10-02:

  an unset JAVA_INSTALLER must fall back to the server's Java (/server/java8), got:
  SPC-SYNTAX-CHECK-READY
  Exception: /tmp/spc-template-probe4573418482037279994/installer-java-probe.ps1:5
     5 | if ($errors) { throw 'the template does not parse' }

The template parses. The probe never read one. It named
`/templates/default_template.ps1` outright, which is where
TemplateInterpreterRunner.runInContainer copies the staged directory and nowhere
else -- runLocally runs the probe where stageTemplates put it, under the JVM's temp
directory. `ParseFile` on a missing path fills $errors like a syntax error does, so a
path fault wore the shape of a verdict against the template.

That is why only one of the two CIs saw it: runLocally is taken when `pwsh` is on the
PATH, which GitHub's ubuntu-latest has and this project's Forgejo runner does not, so
Forgejo has always taken the container branch and been right by accident. ShellTemplateSyntaxTest
never had the bug because it builds its local commands from File(staged, name) and only
spells /templates inside the container arm; this test has one probe body for both arms,
so the body has to work in both.

$PSScriptRoot is that one body: /templates in the container, the staged directory
locally. Verified by running the rendered probe in mcr.microsoft.com/powershell with
the staged directory copied to each location in turn --

  before, staged at /work:       Exception ... the template does not parse   (exit 1)
  after,  staged at /work:       FALLBACK:"/server/java8" -jar quilt-installer.jar
                                 OVERRIDE:"/installer/java21" -jar quilt-installer.jar   (exit 0)
  after,  staged at /templates:  identical output                                        (exit 0)

-- the first of which reproduces GitHub's message line for line.

Added with it: a Test-Path before the parse, so the next path fault says
`no template at /work/default_template.ps1` instead of accusing the template. Its teeth
were checked the same way, by staging the probe with no template beside it. And the
template's name is now one constant rather than a literal in the staging call and
another inside the probe, which is how the two came to disagree in the first place.

:serverpackcreator-api:test --tests "*PowerShellInstallerJavaTest*"
--tests "*ShellTemplateSyntaxTest*" -> 4 PASSED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
grinder-container-it.yml went red twice on 2026-10-01 (runs 831 and 834), six failures
between them, every one a clock running out:

  ContainerOwnershipIT      x2  the fixture never got a container of this engine running within 90s
  DockerJavaContainerEngineIT x2  the fixture never got this engine's container running within 90s
  DockerJavaContainerEngineIT      the probe container should be running before close()
  DockerJavaContainerEngineIT      must stop on ready, not wait out the 120s sleep (took 101s)

Nothing had changed in the engine. The host had: that push queued nine runs at 17:03:00
and the runner ran three Gradle/npm builds side by side for the next 41 minutes, which
shows up in every workflow of the batch, not just this one --

  docker-test.yml          13-15 min  ->  41.6 min  (829, 830; buildkit's own Gradle
                                                     stage 616s -> 1913s, npm 447s -> 817s)
  devbuild.yml             ~20 min    ->  96.6 min  (828)
  docs.yml                 ~25-31 min ->  63.4 min  (832)
  grinder-container-it.yml 5.3-8.8min ->  18.1, 23.3 min (831, 834)
  test.yml                                 836 red, embedded mongod missed a 30s budget

-- so this is the same incident as the previous commit, seen through a different clock.

Two different things were wrong with these numbers, and they want different answers.

**The fixture waits were bounds on the daemon, and they were tuned to the last slow run.**
60s failed run 716, became 90s, then failed 831 and 834. That is a treadmill, and it is also
unnecessary: every one of these waits exits the instant the container is running, so a
generous bound costs a healthy daemon nothing. They now share
FIXTURE_DAEMON_BUDGET_MILLIS = 300_000, named once because ContainerOwnershipIT and
DockerJavaContainerEngineIT wait for the same thing on the same daemon and two copies drift
apart on the first run that only fails one of them -- seven literal 60_000/90_000 between
them before this.

**The "stopped on ready" assertion was a wall clock, and a wall clock cannot answer it.**
30s failed run 726 at 38s; 90s failed run 834 at 101s. Three reds, no defect. It now asserts
on what the container *printed*: the sleeper echoes SLEPT-THROUGH-THE-READY-LINE after its
`sleep 120`, and a run that ended on the ready line can never reach it. Same discrimination,
no host in it.

Teeth checked rather than assumed, by making the ready line undetectable and raising the
run's own timeout past the sleep -- which is exactly the scenario the old comment said the
bound existed for:

  AssertionFailedError: the run must end on the ready line; reaching
  'SLEPT-THROUGH-THE-READY-LINE' means it waited out the container's 120s sleep instead

and the two assertions before it still passed, so the new one is what caught it.

What this gives up, stated because it is not nothing: a daemon that honours the stop but
takes a minute to do it now passes. That was never this test's subject --
closeSignalsAContainerBeforeKillingIt owns the stop path -- and it is the only thing the old
bound caught that `timedOut` does not.

GRINDER_DOCKER_IT=1 :serverpackcreator-grinder:test over both classes -> 12 PASSED in 43s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docker network inspect runners_default` exits non-zero for three unrelated reasons --
no docker CLI in the job, a CLI whose daemon cannot be reached, and a reachable daemon
with no such network -- and the step asserted the third one for all of them:

  ::notice::No 'runners_default' network on this host - pulling from upstream registries.

It printed that on run 830, on a host where the runner and both registry caches ARE on
`runners_default`. The notice was not evidence; it was the step's only hypothesis, stated
as a finding. This repository has already paid for that once -- `ufw status` showing no
rule for a port that `INPUT policy DROP` was dropping anyway, which cost two more wrong
hypotheses before the chain policy was read (CLAUDE.md, "the symptom shared by two
layers"). "No rule for this" and "not filtering this" are opposite statements, and so are
"no such network" and "could not ask".

The probe now checks for the CLI, then asks the daemon for its name and version, then
looks for the network -- reporting which step failed, and on the last one printing both
the daemon that answered and `docker network ls`, so a run says whether the name is wrong
or the daemon is. Exercised through all four paths against a real daemon:

  network absent      -> names the daemon, lists bridge/host/none, driver_opts=
  network present     -> "Found the 'runners_default' network on that daemon",
                         driver_opts=network=runners_default
  daemon unreachable  -> "The docker CLI cannot reach a daemon", quotes docker's own
                         message, driver_opts=
  no docker CLI       -> "No docker CLI in this job", driver_opts=

The network name is now one `env:` per workflow rather than three literals; the commit
that last changed it had to edit six lines across two files.

What this does NOT do is fix the cache, because which of the three it is has not been
established yet -- the next run of either workflow establishes it. The likeliest reading
is written up in claude-docs/RUNNER-REGISTRY-CACHE.md as an explicitly OPEN section
(3a): forgejo-runner's usual compose shape puts job containers inside a `docker:dind`
sidecar, whose networks are not the compose project's, so `runners_default` and the two
cache containers would sit one layer up and out of reach. The doc now also records that
the workflows say `runners_default` where section 1 still says `registry-cache`, and that
the workflows are the copy that decides.

Also recorded, in .claude/rules/ci-workflows.md beside the concurrency entry it belongs
to: the 2026-10-01 batch of nine runs from one push, and the 2-5x slowdown it produced
across every workflow -- which is what took test.yml and grinder-container-it.yml down and
is the thing the two preceding commits treat the symptoms of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test.yml`, `docker-test.yml` and `grinder-container-it.yml` triggered on both `push:`
and `pull_request:`. With the standing `develop` -> `beta` PR open, every push to
`develop` therefore ran each of them twice over the same tree, in parallel, on one
runner, under concurrency groups keyed on `${{ github.ref }}` -- which differs between
`refs/heads/develop` and the PR's ref, so nothing deduplicated them and nothing even
queued them.

What that costs is not theoretical. One push on 2026-10-01 queued nine runs at
17:03:00, and the whole batch ran 2-5x its own previous day's time:

  docker-test.yml          13-15 min  ->  41.6 min  (829, 830 -- both the same commit)
  devbuild.yml             ~20 min    ->  96.6 min  (828)
  docs.yml                 ~25-31 min ->  63.4 min  (832)
  grinder-container-it.yml 5.3-8.8min ->  18.1, 23.3 min (831, 834 -- both red)
  test.yml                 12-22 min  ->  836 red

Every red in that batch was a deadline expiring: four grinder fixtures on a 90s wait,
one wall-clock assertion, and all fourteen of test.yml's database-backed tests on
flapdoodle's 30s mongod budget. The three preceding commits make those deadlines
survive a slow host, which is right but treats the symptom. This is the cause, or half
of it -- the other half is the runner's own job concurrency, which is host
configuration.

Removing `pull_request:` is correct here rather than merely cheap: every PR on this
instance is opened from a branch of this repository -- the long-lived develop -> beta
one and the `claude-` fix branches -- and `push:` already builds exactly those commits.
The second run was a second verdict on a tree that had already been judged.

Two things it does give up, both written into the workflows rather than left to be
discovered:

  - the *merge* result is no longer built on its own. A PR can be green on its head and
    still conflict semantically with its base until the merge lands on `develop` and is
    built there.
  - a fork PR gets no Forgejo CI. GitHub's mirrored `.github/workflows/test.yml` keeps
    `pull_request:`, and GitHub is where a fork actually arrives.

Restoring either is one line.

Comments updated rather than left asserting the old world: `grinder-container-it.yml`'s
header and `test.yml`'s GRINDER_DOCKER_IT note both justified themselves with "a push to
develop also builds PR #678 at the same commit". That specific collision is gone; the
general one is not -- two branches pushed close together still share the runner and its
daemon, which is all `reapsALabelledOrphanLeftByAPreviousProcess` needs -- so the
repository-wide lock stays and the comments now say which case they defend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three records, all from evidence this session produced, none of them a code change.

**The registry cache's "network not found" is settled, and it is not the network.**
RUNNER-REGISTRY-CACHE.md's section 3a was written as an open question with a ranked
guess; Griefed's runner configuration answers it. `docker_host:
unix:///sockets/docker.sock` over a volume shared with a `docker:dind` service means a
job talks to **dind**, not the host -- the shared socket volume makes it look like a
host socket mount and it is not one. So `docker_dind`, the runner and the two
`registry-cache-*` containers are containers of the *host* daemon on `runners_default`,
while every job container and every buildx builder is a container of the *dind* daemon,
one namespace down. The network really is absent from the daemon that was asked and
present on the one that was not.

Three consequences are now written down rather than rediscovered:
`driver-opts: network=runners_default` cannot work from a job while this topology
stands; name resolution fails but *routing* does not, since dind has an interface on
`runners_default` and its children egress through it, which is what makes a static-IP
mirror plausible (and is a claim to verify on the host, not to build on); and
`/etc/docker/daemon.json` on the host is the wrong file for the Docker Hub mirror,
because the daemon pulling busybox, alpine and the PowerShell image is dind's. Two
shapes that can work are laid out with what each costs. Both are host work; the
workflow half is already in place and correctly declines.

**A green CI run and a red one of the same commit were the same failure.** `921d46938`
ran `test.yml` twice. Run 836 failed fourteen database-backed tests; run 833 was
**green having skipped the same fourteen** -- confirmed by reading its log, 14 SKIPPED
across `WebServiceContextTest` and `WebPersistenceIT`, after its probe's mongod reached
`Opening WiredTiger` at 15:53:13.9 and was given up on 29.3 s later. One cause, and
what differed was only which mongod lost the race: the Spring context's (a failure) or
`EmbeddedMongoAvailable`'s probe (a skip, which that guard exists to produce). The guard
worked exactly as designed and converted a red build into a green one that verified
nothing about the database -- which is the cost its own doc comment already named, now
with an instance attached. The pair to check after a red CI run is therefore not "did
the other run pass" but "did it run".

**The whole batch is the measurement, not the job that went red.** Run 836 alone reads
as "mongod is flaky". The run list reads as a host 2-5x slower than the day before with
nine runs queued at one instant, and four different guards expiring for one reason. That
comparison is one API call, and it is the difference between fixing four tests and
understanding one host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table says to re-derive these from `<module>/build/test-results/test/*.xml` rather
than trust the column, and a `./gradlew build` on this branch produced a set to read:

  api 503, app 232, clientside 673, grinder 542 (27 skip), plugin-example 3,
  plugin-grinder 75, plugin-selfextract 17, plugin-servertest 112 (1 skip)
  = 2157 JVM tests, 0 failed
  web-frontend 37 across 15 files (`:serverpackcreator-web-frontend:check --rerun-tasks`)

Two cells had drifted: grinder 539 (24 skip) -> 542 (27 skip) and servertest 111 -> 112.
Neither moved in this branch -- nothing here adds a test -- so the drift is from earlier
work that did not update the column, which is exactly what the header warns about.

The grinder's 27 skips are its env-gated ITs; GRINDER_DOCKER_IT=1 turns 12 of them into
the container suite `grinder-container-it.yml` runs, verified green separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci: reach the registry caches by fixed IP, and probe what actually matters
All checks were successful
Continuous / Build JAR (push) Successful in 11m58s
Documentation / Writerside webhelp (push) Successful in 4m2s
Documentation / Dokka HTML (push) Successful in 5m30s
Docker Test / build image (push) Successful in 25m39s
Grinder Container IT / containers (push) Successful in 20m17s
Continuous / Build AppImage (x86_64) (push) Successful in 3m59s
Continuous / Build AppImage (aarch64) (push) Successful in 3m8s
Continuous / Build Install4J Media (push) Successful in 11m17s
Documentation / Help image (push) Successful in 7m40s
Qodana / notify (push) Successful in 57s
Qodana / scan (push) Successful in 20m6s
Continuous / Continuous Pre-Release (push) Successful in 8m39s
Test / build (push) Successful in 1h6m15s
ad3fa0376d
Option 2 of claude-docs/RUNNER-REGISTRY-CACHE.md, chosen by Griefed: the caches stay on
the host daemon and are addressed by pinned IP. The repository half is this commit; the
host half is the compose in section 1 of that file.

WHY IP AND NOT A SERVICE NAME. Jobs run inside the `docker:dind` daemon -- the runner's
`docker_host` is dind's socket over a shared volume, which looks like a host socket mount
and is not one -- while the caches are containers of the host daemon. dind's embedded DNS
does not know them; routing does reach them, because `docker_dind` has an interface on the
cache network and its children egress through it.

MEASURED, not reasoned, on a reproduction of the topology (`docker:dind` on a user-defined
network, `registry:3` beside it, 2026-10-02). Four results, all from a container created by
the dind daemon:

  wget http://registry-cache-ghcr:5000/v2/   -> bad address        (names: no)
  wget http://172.31.77.6:5000/v2/           -> 200 {}             (routing: yes)
  dind attached to the cache network         -> 200
  dind detached, nothing else changed        -> download timed out

The last pair is why "attach dind to the cache network" is in the host recipe as
load-bearing rather than as boilerplate, and the failure being a *timeout* is why its
absence would read as "the cache does not work".

End-to-end through the path the workflow actually uses: a buildx builder created by the
dind daemon, configured with these stanzas and **no `--driver-opt network`**, built
`FROM alpine:3.20` and `FROM ghcr.io/linuxserver/baseimage-alpine:3.20`. The Docker Hub
cache went 51 -> 55 `GET /v2/` and the ghcr cache served 3, manifest and blobs, source
address being dind's. `dockerd --registry-mirror` inside dind pulled busybox through the
Hub cache in the same run: 8 requests.

THREE CHANGES, each with a reason beyond tidiness:

  - `driver-opts: network=...` is GONE. With fixed IPs the builder has no network to join,
    and removing it removes the only way this step could kill a build outright --
    `network runners_default not found`, which is what broke run 797.
  - The probe asks the question that matters. "Does this network exist" was a proxy for
    "can the builder reach the cache", and on this host it answered about the wrong daemon.
    A `GET /v2/` from the job takes the same path the builder takes, both being children of
    the same dind daemon.
  - It fails soft per registry. Each cache that answers contributes its stanzas; each that
    does not is left out. Verified with one of the two stopped: the config came back
    carrying docker.io and not ghcr.io, and a host with no caches at all gets
    `# no registry cache answered`.

`buildkitd-config` (a path) rather than `buildkitd-config-inline`, because the config is now
computed; the input exists at the pinned sha e468171a (checked against its action.yml, not
assumed). The probe itself was run in all four states -- both caches up, one down, none
reachable, and from inside dind where CI runs it.

The two `env:` addresses are the one coupling to the host. If they drift, the probe finds
nothing and the build goes upstream; a wrong address must never be a failed build.

Docs rewritten around the verified recipe: the topology first (section 0), the compose with
the `networks:` landmine that would silently take dind off `runners_default`, the
attach-dind measurement, and an explicit table of what this covers and what it does not --
`mcr.microsoft.com/powershell` and the ghcr job image remain uncached, because Docker Engine
can only mirror Docker Hub. Restored the misspelled-`REGISTRY_PROXY_*` trap that an earlier
pass dropped: a bad override yields an empty *writable* registry that 404s every pull and
looks like a network fault.

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!682
No description provided.