EIN BLOCK, ZWEI BLOCK, DREI BLOCK, VIIIIIIIER #671

Merged
Griefed merged 33 commits from develop into alpha 2026-08-23 10:48:35 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-08-23 09:35:33 +02:00
`npx -p <name>` with no version resolves to the newest major on every run, so three of the four
packages this step installs were floating. One of them moved and took the release with it:
conventional-changelog-conventionalcommits@10 switched to @conventional-changelog/template, whose
createLegacyWriterGuard() deliberately plants a bogus `mainTemplate` so that a pre-9 writer throws
instead of silently emitting an empty changelog. semantic-release@24 depends on
@semantic-release/release-notes-generator@14, which depends on conventional-changelog-writer@^8 -- a
legacy writer -- so the guard fired and generateNotes died with

    Missing helper: "conventional-changelog-conventionalcommits requires
    conventional-changelog-writer@9 or newer ..."

Measured against @semantic-release/release-notes-generator@14.1.1, calling generateNotes directly:

    preset  8.0.0   renders
    preset  9.3.1   renders
    preset 10.4.0   throws the error above

Preset 9 is therefore the ceiling, and upgrading semantic-release is not an escape hatch: 25.0.9 still
depends on release-notes-generator ^14.1.0, hence still writer 8. release-notes-generator@15, which
would bring writer 9, is beta-only. Unpin once that ships and semantic-release depends on it.

Rehearsed end to end against a local bare clone of this repository standing in for origin: with the
pin, generateNotes completed with zero errors over the real 229 commits since 9.0.0-alpha.5, rendering
every custom section title from .releaserc.yml. changelog and git are pinned to their current majors
for the same reason, not because either is currently broken.

This fixes the crash only. The same run also resolved the wrong last release (8.1.2 instead of
9.0.0-alpha.5) for an unrelated reason -- see claude-docs/RELEASE-TAG-REPAIR.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-08-22 release-generate.yml run failed twice over: it crashed in generateNotes, and before
that it had already picked the wrong last release -- `Found git tag 8.1.2 ... on branch alpha` with
9.0.0-alpha.5 sitting right there. Neither is visible in the workflow YAML, so both go in the rule
file that loads when .forgejo/** is touched.

Landmine 1: semantic-release stores a release's channel in refs/notes/semantic-release, not in the
tag. get-tags.js falls back to `channels = [null]` for a tag with no note, and get-last-release.js
keeps a prerelease branch's tags only when their channels match the branch channel -- so on a remote
with zero notes every X-alpha.N tag is invisible and only the newest non-prerelease tag survives. The
Forgejo remote has 375 tags and no refs/notes/* at all, because a forge migration carries branches and
tags but not notes. Also recorded: actions/checkout needs no change, since semantic-release runs
fetchNotes itself -- verified against a fresh clone whose only copy of the notes was on the remote.

Landmine 2: creating a release through the forge API mints a missing tag at target_commitish.
9.0.0-alpha.1 through .5 still report "target_commitish": "main", and all five tags sit on main's tip
(the RELEASE: 8.1.2 commit) instead of on their own RELEASE commits. Comparing all 375 remote tags
against local bounds the damage exactly: those five, plus the ghost 9.0.0-alpha.6, plus `continuous`
which is supposed to move; the other 368 match byte for byte. release-build.yml is explicitly cleared
-- it posts no target_commitish to Forgejo -- and how alpha.6's tag reached that commit is stated as
unrecoverable rather than guessed at.

RELEASE-TAG-REPAIR.md is the runbook for the honest repair Griefed chose: retarget .1-.5, delete the
ghost .6 release and tag, write the channel notes. It is written to be run by hand and it was
rehearsed against a local bare clone of this repository standing in for origin:

    broken   (tags forced onto 6cd6e9af3, no notes)   last=8.1.2          624 commits   next=9.0.0-alpha.1
    repaired (retargeted, .6 deleted, notes pushed)   last=9.0.0-alpha.5  229 commits   next=9.0.0-alpha.6

The broken row reproduces the failing CI run down to the commit count, which is what makes this a
finding rather than a theory. Also written down: the dry-run log flattens the changelog heading's
markdown link and indents the bullets, which looks like a format regression and is only signale
rendering multi-line output -- the written string is byte-identical in shape to existing CHANGELOG.md
entries under both preset 8 and preset 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PathsConfig` resolved a source build's home from `File("").absolutePath`, which is unobservable from a test:
the JVM resolves an empty path against the working directory it was *launched* with and ignores a later
`user.dir` (verified — setting the property mid-process does not move `File("").absolutePath`). Taking it as a
constructor parameter with that same value as the default lets a test present a different one.

Behaviour-preserving: the default is the expression it replaces, and ApiProperties, the only caller, does not
pass it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guards are red at this commit, and their failures are the two halves of the reported defect:

- `theDevEnvironmentFallbackSkipsAnUnwritableWorkingDirectory` — the resolver adopts a working directory it
  cannot write to, which is the `/` a systemd unit without `WorkingDirectory=` hands a source build.
- `anUnusableHomeDirectoryFailsWithAnActionableError` — fails with `java.io.FileNotFoundException`, the very
  exception the reported service crash died on, instead of naming the home it could not use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the two red guards green, and with them the reported service crash: a locally built artifact is a source
build (`version=dev`), a source build fell back to the process working directory, and systemd starts a unit in
`/` unless the unit file says otherwise — so the home resolved to `/`, `log4j2.xml` could not be written, and
the unguarded read that follows it killed the process with `FileNotFoundException: /log4j2.xml`, a message that
names neither the home nor where it came from.

Three changes, all on that path:

- `PathsConfig` takes the working directory as a home candidate only when it may write there. The next
  candidate, the user's home, is what a service then gets.
- `ApiProperties.init` probes the resolved home before using it and fails with the path, the `-D` that
  overrides it, the `/` explanation and the Preferences node holding the stored value. The probe writes a file
  rather than trusting `canWrite()`, which lies about directories on Windows.
- `setLoggingLevel` reports an unreadable or unwritable `log4j2.xml` instead of throwing. Recording a log level
  is a setting, not a reason to take down the caller — and the write above it was already tolerant, which is
  precisely the asymmetry that made an unwritable home fatal.

Behaviour change for embedders (see claude-docs/API-BEHAVIOUR-CHANGES.md): the `logLevel` setter no longer
propagates an IOException, and constructing ApiProperties against an unusable home now throws
IllegalStateException.

api suite: 356 tests, 1 skipped, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same statements, same place in the sequence, now behind a named function so a guard can call it. Enabling
change for pinning *when* the daemon claims its SPC environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red at this commit: the node claim sits *after* main()'s first log line, and no home claim exists at all.

Both matter because ApiProperties is registered as log4j's ConfigurationFactory, so the first log statement in
the process constructs one — the reported crash's stack starts in GrinderApplication.getLog, before main had
wired anything. Asserted against the source, since a JVM whose logging is already initialised cannot observe
the ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the ordering guard green and closes the reported service failure at its source. The daemon claimed its
Preferences node but never its home, so SPC resolved one itself — and a source build (every locally built
artifact) falls back to the process working directory, which systemd sets to `/`. Verified 2026-08-22 by
running the installed distribution from `/`: it died on `FileNotFoundException: /log4j2.xml` before reaching
Docker.

- `pinSpcHomeDirectory` names the base as SPC's home unless the operator set `-Dde.griefed.serverpackcreator.home`
  themselves. As a system property it outranks the stored preference without replacing it, so a host that already
  remembered the bad `/` is repaired by an upgrade rather than needing the preference cleared by hand.
- Both claims moved above main()'s first log statement, which is what actually builds the ApiProperties.
- `SPC_GRINDER_HOME` makes the base configurable, since it is now SPC's home as well; documented in §5, which
  the README-drift guard requires.
- The startup line now states the home, the one path it was silent about.

The three guards on `pinSpcHomeDirectory` arrive with it: they name the function, so they cannot compile before
it exists. The ordering guard — the half that pins the defect — landed red in the preceding commit.

grinder suite: 237 tests, 19 skipped (gated integration tests), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grinder README's §8 unit file was missing `WorkingDirectory=`, and §7 claimed SPC's logs live under
`~/.spc-grinder` while nothing made that true — the two halves of the reported failure, one in the how-to and
one in the code. Now: the unit sets `WorkingDirectory=` and `SPC_GRINDER_HOME` with the reason each is there,
§7 states that the base *is* SPC's home and names the `-D` that moves it, and §9 has a row for both symptoms
an operator will search for.

Also landmined where the next reader will trip: that ApiProperties is log4j's own ConfigurationFactory, so the
first log statement in a process constructs one (-api and -grinder CLAUDE.md), and the two API behaviour
changes the fix carries (claude-docs/API-BEHAVIOUR-CHANGES.md).

Suites after the change: api 356/1 skipped, grinder 237/19 skipped, app and clientside unchanged — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the grinder as a systemd service died on `FileNotFoundException: /log4j2.xml` before reaching Docker.
Two defects lined up: a source build resolves its home to the process working directory, which systemd sets to
`/`, and the daemon pinned its Preferences node but never its home — and did that after its first log
statement, which is what actually constructs an ApiProperties (it is log4j's ConfigurationFactory).

Reproduced from `/` with the installed distribution, and re-run there after the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red at this commit, and the measurement is the finding (audit iteration 12, L1): 34 of 64 concurrent probes on
one writable directory reported it unwritable, and a directory merely containing something called `poke` reports
unwritable deterministically.

`testFileWrite` probes by writing a file called `poke` — one fixed name for every caller and every process.
That was survivable while the answer only made a GUI file-chooser refuse a directory. It stopped being
survivable when `ApiProperties.requireUsableHomeDirectory` put the probe on the construction path behind a
throw, one commit earlier on this branch: each false answer is now a process that dies at startup naming a home
that is fine. Concurrent SPC processes sharing a home is the documented normal condition here, and the grinder
probes twice per start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the red guards green and closes audit iteration 12's only HIGH — a defect this branch created two commits
earlier: `requireUsableHomeDirectory` put `testFileWrite` on every ApiProperties construction behind a throw,
and `testFileWrite` probed by writing a file called `poke`, one fixed name for every caller and process.

Measured before: 34 of 64 concurrent probes on a writable directory answered "unwritable", each of which is now
a process that refuses to start; and a directory merely containing something called `poke` answered the same,
deterministically. After: 0 of 64, because the probe name is generated per call.

`Files.createTempFile(dir, ".spc-write-probe", null)`, removed in a `finally` so a failed probe cannot leave
litter in a user's home — the old path could, and with a fixed name that litter broke the next probe.

Contract unchanged: false on failure, IllegalArgumentException on a non-directory. api and app suites green; the
app matters here, since the four GUI file-chooser call sites are there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 12, L2. The window was `substringAfter("fun main(args: Array<String>) {")` — main's body *plus
every declaration below it* — so both `indexOf` calls could match text that is not main: the
`pinSpcHomeDirectory` declaration, or a helper's log call. It passed for the right reason only because main
happens to precede the helpers in the file; reorder them, or drop main's logging, and it would go green while
asserting nothing. That is the failure class this repo has recorded twice already.

Now cut by brace-matching from main's opening brace, and the extractor asserts its own boundedness so the window
cannot silently run past main again.

Teeth checked by mutation, which is the point of the finding: moving both claims back below main's first log
statement turns the guard red with the intended message, and reverting turns it green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: audit iteration 12 — the systemd home-resolution branch, and its resolutions
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m51s
Continuous / Build JAR (push) Successful in 12m34s
Qodana / scan (push) Successful in 13m2s
Docker Test / build image (push) Successful in 14m55s
Continuous / Build AppImage (x86_64) (push) Successful in 2m45s
Continuous / Build AppImage (aarch64) (push) Successful in 2m56s
Documentation / Help image (push) Successful in 7m13s
Qodana / notify (push) Successful in 2m4s
Continuous / Build Install4J Media (push) Failing after 5m57s
Continuous / Continuous Pre-Release (push) Has been skipped
Test / build (push) Successful in 17m5s
3e873af88e
Self-audit of the seven commits merged by 8008c4160, appended to claude-docs/REFACTOR-AUDIT.md: one HIGH (the
writability probe's fixed name, now fixed), one MEDIUM (the ordering guard's window, now bounded), four LOW, and
the list of things verified clean so nobody re-litigates them — chiefly that the working-directory injection is
behaviour-preserving, that no pre-existing assertion changed on the branch, and that SPC's home sharing a
directory with the grinder's state does not collide.

Also here: the root CLAUDE.md snapshot date moved with the table it heads (L5), the grinder CLAUDE.md records the
now-expected doubled 'Loaded properties from …' line (L3), and GrinderApplication says why the base is created
where it is (L4 — comment only, no behaviour).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report server has accepted a `host` since it was written, defaulting to
loopback, and `main` has never passed one. Nothing said it should, so the
daemon binds 127.0.0.1 and no containerised reverse proxy can reach it —
it dials the host over the bridge gateway, which a loopback socket refuses
before any HTTP happens.

Two guards, red, plus one executable pair that already passes:

- ReportBindWiringTest (RED) — main reads SPC_GRINDER_HOST and hands it to
  ReportServer as `host`, and defaults it to loopback. The join is the part
  no unit test can execute, since main boots Docker, so it is stated against
  main's own text, bounded to main's body.
- ReportServerBindAddressTest (PASSES) — connects from a real non-loopback
  IPv4: the default refuses there (ConnectException), a configured address
  answers 200. This is the mechanism the wiring guard cannot execute, and it
  reproduces the reported symptom directly; observed against 192.168.8.113.
  Skips where the host has no non-loopback IPv4.

mainBody() moves from GrinderSpcEnvironmentTest to GrindTestFixtures as
grinderMainBody() so both wiring guards share one window; no assertion
changed in the move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_HOST, defaulting to 127.0.0.1, passed to ReportServer as `host`.
The port has always been configurable; the address never was, so the report
bound loopback unconditionally and no containerised reverse proxy could reach
it — such a proxy dials the host over the Docker bridge gateway, and a
loopback socket refuses that at the TCP layer. Symptom is a 502 from the proxy
while the report answers fine over an SSH tunnel.

The default does not change: the report is unauthenticated — `/`, `/status`
and `/export.csv` all answer unconditionally — so exposure stays a deliberate
act. README §5 *Exposing the report* recommends the gateway address over
0.0.0.0 for that reason, and §9 gets the symptom row.

Also: the startup banner logs `bind=`, and the "Report:" line now prints the
bound host instead of a hardcoded "localhost", which under a non-default bind
was a URL the operator could not reach — and the journal is where they look.

Turns the two guards from the previous commit green. The regex in
ReportBindWiringTest is widened to match across newlines, since the
construction it inspects is now wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suite is 241 after the two bind guards. The skip count is a range on purpose:
ReportServerBindAddressTest needs a real non-loopback IPv4 to cross an
interface boundary, and skips on a host without one — observed both ways on
this machine as its wifi flapped, 19 skips with the interface up and 21 with
it down. A fixed number in that column would be wrong half the time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving: reportUrl(bindHost, port) returns exactly what the
interpolation in main() returned. Lifting it out is what makes it reachable
from a test at all — the line lives in main(), which boots Docker and cannot
be executed by the suite.

Existing guards unchanged and green (ReportBindWiringTest,
GrinderSpcEnvironmentTest); no assertion moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L1 and L2. Making the bind address configurable made the
logged URL follow it, which is right for a concrete address and wrong for the
two other shapes a legitimate bind can take.

Red on three of four:

  port lost for bind '::1': http://::1:8757 ==> expected: <8757> but was: <-1>
  ipv6LiteralsAreBracketed  expected: <http://[::1]:8757> but was: <http://::1:8757>
  aWildcardBindIsReportedAsLoopback expected: <http://127.0.0.1:8757> but was: <http://0.0.0.0:8757>

The IPv6 case is the one worth noting: URI.create does not throw on the
unbracketed form, it silently parses the port as -1, so nothing would ever
have surfaced this at runtime.

concreteIpv4AddressesAreLeftAlone passes already — the ordinary path is
pinned before it is touched, so the fix is provably confined to the two
broken shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L1 and L2. A wildcard bind means "every interface", which
is not a destination, so it is now reported as the loopback the report is
certainly answering on; an IPv6 literal is bracketed per RFC 3986, without
which URI parses the port as -1 and says nothing.

Turns the previous commit's three red guards green. concreteIpv4Addresses-
AreLeftAlone was green before and after, so the ordinary path — the only one
either the default or the documented gateway recommendation ever takes — is
demonstrably untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L3 and L4. No expectation changes; all four guards green
before and after.

L3 — four new !! went in against the Kotlin convention, all of them only
because assumeTrue and assertNotNull do not smart-cast. nonLoopbackIpv4()
now returns String and throws TestAbortedException itself, which JUnit
reports as a skip exactly as the assumption did; the two regex lookups use
elvis into Assertions.fail, which returns Nothing.

L4 — theDefaultIsReachableOnLoopbackOnly asserted ConnectException while
claiming "unreachable". A host that DROPs rather than REJECTs delivers that
same verdict as a connect timeout, and the narrower type would have failed
on a box where the guarded property holds. Widened to IOException, the
common supertype of both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of 97f487e0e..HEAD. Thirteen commits were iteration 12's and are
re-affirmed rather than re-litigated; scrutiny fell on the four new ones.
No HIGH. Two MEDIUM, six LOW; all fixed except M2 (already-merged history)
and L5 (recorded — every fix costs more than the flake).

Also backfills REFACTOR-LOG.md, which M1 caught two branches stale at
2026-08-17: entries for the 2026-08-22 systemd home-resolution branch and
for this one. Iteration 12 had not caught that about itself.

Grinder suite 245, zero failures, 38 classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/spc-grinder.service lists all 16 variables the service reads — the 15
SPC_GRINDER_* plus CURSEFORGE_API_KEY — commented out with their real
defaults. SPC_GRINDER_HOME is the one deliberately left active: its code
default follows the home of User=, so pinning it means changing the account
cannot silently relocate the daemon's state.

Two things in it are load-bearing rather than decorative:

- WorkingDirectory=, because systemd starts a unit in / and that is the
  FileNotFoundException: /log4j2.xml this service first died on.
- No PrivateTmp=, ProtectHome= or other namespacing. The grinder bind-mounts
  host paths into its containers and the *docker daemon* resolves those in
  the host namespace, so a path this unit can see but the daemon cannot fails
  at container creation, pointing nowhere near systemd. NoNewPrivileges and
  ProtectSystem=full namespace nothing and are safe.

deploy/install-grinder.sh does the four steps: image, installDist, install to
/opt/spc-grinder, service account with home and docker group. Notes:

- It refuses to run as root. The Gradle build has to run as the invoking user
  or it leaves root-owned files in build/; the privileged steps call sudo
  themselves, with one sudo -v up front rather than four scattered prompts.
- The distribution's bin/ and lib/ go to /opt/spc-grinder/, which puts the
  launcher at /opt/spc-grinder/bin/serverpackcreator-grinder as ExecStart
  expects. lib/ is replaced wholesale, not merged: the launcher pins an
  explicit jar list so a stale jar is never loaded, but a version bump renames
  one and merging would accumulate every version ever installed.
- A running service is stopped before its jars are replaced and started again
  afterwards. Replacing jars under a live JVM surfaces hours later as a
  class-loading failure with nothing tying it back to the install.
- The unit is copied only with --install-unit, and never enabled or started;
  those commands are printed instead.

.gitignore needed a scoped exception: `deploy/` was already ignored as a
build-output directory by the JDeveloper/IDEA template block, which silently
swallowed both files on the first attempt to commit them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same job ReadmeConfigurationTest does for the README table, for the unit: a
knob added to the service and forgotten in the unit is invisible to whoever
deploys from it, and a default quoted there that the code no longer uses is
worse than no comment — it reads as authoritative.

Teeth confirmed by mutation rather than assumed, since all four passed on
first run and this project has twice shipped a guard that asserted nothing.
Four mutations, four distinct failures:

  drop #Environment=SPC_GRINDER_BATCH=25  -> does not mention SPC_GRINDER_BATCH
  add  #Environment=SPC_GRINDER_BOGUS=1   -> declares variables nothing reads: [SPC_GRINDER_BOGUS]
  PORT 8757 -> 9999                       -> wrong default for SPC_GRINDER_PORT
  comment out WorkingDirectory=           -> no active WorkingDirectory=

Unit restored byte-identical afterwards (verified by diff) and all four green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of the two deployment commits, run immediately after they landed. No
HIGH; one MEDIUM and three LOW, all fixed here.

M1 — PREFIX, SERVICE_USER and SERVICE_HOME are overridable while the unit
hardcodes all three, so an override produced a successful install the unit
could not start. The installer now parses User=, WorkingDirectory= and
ExecStart= out of the unit and reports any disagreement. A warning, not a
failure: an operator with their own edited copy is doing nothing wrong.

L1 — rm -rf "${PREFIX:?}/lib" was guarded against an *unset* PREFIX, which is
not the dangerous case. PREFIX=/ reached `rm -rf /lib`. PREFIX must now be
absolute and at least two components deep; / and /usr are both rejected by
name, verified.

L2 — "one sudo prompt up front" was untrue: the timestamp lasts ~15 minutes
and a cold image build plus a Gradle build outlives it. Refreshed before the
privileged block, and the comment now describes what happens.

L3 — the header said to run it from the repository root, which the script
does not care about; repo_root comes from BASH_SOURCE.

Verified rather than assumed, in the shell: cp -a merges on a re-run instead
of nesting bin/bin, the exec bit survives git as 100755, and the deploy/
gitignore exception holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 15. The iteration-13 fix brackets any address containing a
colon, which double-brackets one an operator wrote in bracketed form — and
that form is legitimate: verified against the JDK's HttpServer, which binds
"[::1]" happily and reports 0:0:0:0:0:0:0:1.

Red:  expected <http://[::1]:8757> but was <http://[[::1]]:8757>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 15, L1 and L2 — both defects introduced by iterations 13 and
14's own fixes, which is what the third pass was for.

L1 — reportUrl bracketed any address containing a colon, so a bind an operator
wrote as "[::1]" came back as http://[[::1]]:8757. The bracketed form is
legitimate: HttpServer binds it and reports 0:0:0:0:0:0:0:1. Turns the
previous commit's guard green; the other four cases were green before and
after.

L2 — iteration 14's own L3 fix added a line to the header block, which pushed
the last usage line out of the fixed `sed -n '2,21p'` range, so --help
silently stopped documenting --skip-image. Replaced with an awk that prints
the contiguous comment block however long it grows. A fixed line range was
wrong within one commit of being written, which is this project's "cite names,
not snapshots" rule showing up in a shell script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No HIGH. One MEDIUM recorded, two LOW fixed — and both LOWs were defects that
iterations 13 and 14's own fixes introduced, which is the case for running a
third pass at all.

M1 is against my own work and stays recorded rather than corrected:
ad7aff574 is labelled refactor: while widening an existing assertion from
ConnectException to IOException. The conventions call that the stop-and-flag
signal outright, and the reference-only carve-out does not apply — no symbol
moved, the expectation changed. Same disposition as 358675fbf already in this
file: the commit is on develop, the body is honest, only the type lies, and
rewriting merged history to relabel it costs more than it returns.

Grinder suite 250, zero failures, 39 classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Targeted review of deploy/install-grinder.sh and deploy/spc-grinder.service,
verified against real runtimes rather than reasoned about: shellcheck,
systemd-analyze verify, and useradd in Debian containers.

Two HIGH. Nothing provides or checks Java, and the Gradle launcher cannot
start without JAVA_HOME or java on PATH — which systemd does not supply;
the installer builds with Gradle so the operator's own PATH hides it until
the first systemctl start. And an existing account named by SERVICE_USER is
added to the docker group without confirmation, which is root-equivalent.

Three MEDIUM: Group=grinder can name a group useradd never created
(reproduced with USERGROUPS_ENAB no — gid lands on 100(users)); a failure
after the service is stopped leaves it stopped; --install-unit installs a
unit it has just warned is mismatched.

Read-only, per the audit rule. No source touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 16, H1. Red:

  the unit does not mention JAVA_HOME — the launcher reads it and systemd
  will not supply it

The three variables are read by the Gradle launcher rather than by any
Kotlin, so no env(...) call names them and the existing phantom-variable
guard would have rejected them; they are whitelisted as launcher-read for
exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All ten, verified on real Linux in Debian and Fedora containers rather than
reasoned about. shellcheck clean at -S style, systemd-analyze verify reports
no unit defects.

H1 — nothing provided or checked Java, and the launcher cannot start without
JAVA_HOME or java on PATH. The unit gains a JVM section naming the exact
error and systemd's actual PATH, plus JAVA_HOME/JAVA_OPTS/
SERVERPACKCREATOR_GRINDER_OPTS. The installer checks against systemd's PATH
via `env -i`, not the caller's — the operator has java from a profile the
service never reads, which is what made this invisible. Observed firing in a
container with no JDK.

H2 — an already-existing account was added to the docker group silently.
That group is root-equivalent. An account the script *creates* is still
added automatically; one that already existed now requires --grant-docker.
Verified: refused, and `id -nG grinder` unchanged; granted with the flag.

M1 — Group=grinder can name a group useradd never created. Reproduced with
USERGROUPS_ENAB no (gid landed on 100(users), no grinder group), so the unit
would fail to start on an unresolvable group. The script now reads Group=
out of the unit and creates it if missing. Verified on that same host: group
created, unit's Group= resolves.

M2 + L1 — `set -E` was inert with no ERR trap, and a failure after the
service was stopped left it stopped. Both fixed by the same pair of traps:
ERR reports the failing line, EXIT restarts the service if the install died
after stopping it.

M3 — the unit/install consistency check ran after everything was installed,
and --install-unit then installed the mismatched unit anyway. Moved into
preflight and made fatal when it would install. Verified: an override plus
--install-unit now dies before anything is built.

L2 — chmod a+rX adds read and cannot remove write, so a permissive umask
carried group-writable modes into the service's own binaries. go-w first.
Verified 755 root-owned.

L3 — docker build --pull by default, with --no-pull for an offline rebuild.
L4 — SyslogIdentifier=spc-grinder.
L5 — Documentation lists git.griefed.de first, GitHub second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record iteration 16's resolutions, and the JVM trap in the README
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m40s
Qodana / scan (push) Successful in 7m45s
Continuous / Build JAR (push) Successful in 11m48s
Docker Test / build image (push) Successful in 14m9s
Test / build (push) Successful in 14m48s
Docker Test / build image (pull_request) Successful in 12m44s
Test / build (pull_request) Successful in 14m32s
Documentation / Help image (push) Successful in 8m27s
Qodana / notify (push) Successful in 25s
Continuous / Build AppImage (x86_64) (push) Successful in 2m41s
Continuous / Build AppImage (aarch64) (push) Successful in 3m15s
Continuous / Build Install4J Media (push) Successful in 11m47s
Continuous / Continuous Pre-Release (push) Successful in 7m42s
845fb6381b
README §8 gains the Java requirement — it is the deployment's most likely
first failure and the least self-evident, since the JDK you build with comes
from a profile the service never reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed merged commit 98cf873e6a into alpha 2026-08-23 10:48:35 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!671
No description provided.