You seem lonely #682
No reviewers
Labels
No labels
accepted
bug
dependencies
docker
documentation
duplicate
enhancement
github-actions
github_actions
good first issue
gradle
hacktoberfest-accepted
help wanted
invalid
javascript
not-an-issue
npm
question
rejected
wontfix
Working on it
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Griefed/ServerPackCreator!682
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
`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>`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>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>_experimental, decided in one place d8cec53475A 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>"`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>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>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>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>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>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>