A milestone branch on its way to main (milestone/v0.4.0, #182) collects
work that was agreed and reviewed piece by piece, each piece closing its
own issue. It has no issue of its own to name, and listing the pieces'
issues only works once each has landed, so the required linked-issue
check refused it. `deployment` joins `bug`, `chore` and `dependencies` as
a label that needs none.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.
Every remaining file resolves to the same `docker compose config` as
before.
Closes#30
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The linked-issue check now fails a PR that closes no issue, unless it is labelled bug, chore or dependencies, and re-runs on label changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every job of the E2E workflow ran `composer install`, whose post-install
importmap:install fetched all 45 packages from cdn.jsdelivr.net: about 270
requests per run. jsDelivr builds a `+esm` bundle on request, and now and
then one times out ("Idle timeout reached for .../openpgp@6.3.2/+esm",
"Failed to connect to cdn.jsdelivr.net"), which failed the job before a
test ran.
assets/vendor/ is now cached, keyed by importmap.php. importmap:install
skips a package already there at the version importmap.php names, so the
CDN is asked once per change to the importmap. When it still fails, the
install is tried up to three times; a retry finds vendor/ in place and
only reruns the scripts.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Issue forms, a PR template, area labels and a linked-issue warning on PRs, and CONTRIBUTING.md "How work gets in": a feature needs an accepted issue first, milestones are releases, main is protected. The roadmap checklist moved to issues #126-#130.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → Updates: an administrator picks which builds count as an update
(Releases, the `latest` image; Main, the `main` image; or Off), and
every hour app:updates:check asks whether that channel has a newer one.
Until somebody picks, an installation follows the tag its own build
came from, so a `main` image follows main and an unbuilt checkout asks
nothing of anyone.
The registry decides what exists: the channel's image is read the way
`docker pull` finds it (OCI distribution API, anonymous token from the
401's challenge, the index entry for this architecture, the config's
labels), so a commit that builds no image is never announced. GitHub's
compare decides which way two builds differ, so a main build following
releases is not told to go back to an older release, and its commit
list is the page's "what's new". An image from anywhere but GitHub falls
back to what the version numbers alone can say.
Each new build is announced once: a push to every administrator's
registered devices (a new UpdateAvailable payload the service worker
shows), a badge beside the build chip in the admin header, and a dot on
the nav entry. A check that gets no answer keeps the last one and says
why, and the hourly command exits 0 for it: an installation behind a
firewall is not a fault.
Images are stamped with where they are published (APP_IMAGE), so a fork
that publishes its own checks its own. The channel travels in config
backups; what the last check found does not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.25 moved whole-view bulk actions onto a queue and gave the browser stack
a consumer for it, and then left every OTHER way of bringing that stack up
naming only `app`. `up <service>` starts that service and its DEPENDENCIES; a
worker consuming a queue the app writes to is a dependent, and compose has no
way to know it matters. So CI queued a whole-view archive to a real Postgres
transport with nothing to pick it up, and bulk-select-all.spec.ts waited
fifteen seconds for rows that were never going to move.
Reproduced before fixing, by stopping the worker and running the spec: the
same two tests, in the same file. With the worker named, three pass in seven
seconds.
The same line existed in three places — the CI composite action, the
test:env:up script and the handbook's worktree recipe — and all three now name
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One job, one two-core runner, PHPStan then PHPUnit then ~690 browser
tests in that order. Everything waited for everything, and the part that
takes the time waited on two static checks that say nothing about
whether a page renders.
It is six jobs now: `image` builds once so the rest start from a warm
layer cache and no two of them race to write the same scope, `static`
runs PHPStan and PHPUnit beside the others rather than in front of them,
four `browser` shards divide the suite with Playwright's own `--shard`,
`browser-exclusive` takes the install-wide specs, and `report` merges
their blobs into the one HTML report a person downloads.
A STACK PER JOB IS THE POINT, not a side effect. A single app container
was already the ceiling on worker count, so eight browsers against one
of them would have bought nothing. It is also what frees the four specs
that own install-wide state — the AiSettings singleton, the integration
provider rows, the Mercure hub one of them restarts. They ran last,
alone, in one worker, behind everything else; they were never slow, they
were serialised, because there was one installation to collide over. On
a runner of their own there is nothing left to collide with, which is
why the job passes --no-deps: the project's `dependencies` on the main
one is exactly right for a developer running against ONE stack, and here
would only mean running the whole suite twice.
The setup those six jobs each repeat is now one composite action rather
than six copies of eighty lines, which is the argument compose.test.yaml
already makes about owning DATABASE_URL.
measured, 4 workers, whole suite 4m55s
1/4 168 tests 2.8m account-*, admin-*, appearance*, calendar*
2/4 169 tests 1.3m
3/4 155 tests 1.4m
4/4 137 tests 0.7m
exclusive 23 1.1m
The numbers are in the matrix comment because they are the argument for
whatever the count becomes next: Playwright divides by test COUNT and
the counts are even, while the durations are not — the first quarter
holds the specs that navigate and measure layout. A run is as fast as
its slowest shard, so that block is where the next gain is, and more
shards is the only lever since there is no duration-aware split.
Two things fell out.
A pull request stops paying for superseded runs — a second commit
cancels the first one's. Tags are never cancelled; a release is the run
whose answer gets kept.
And admin-card-insets.spec.ts has been failing since v0.2.15, taking two
minutes and a CI retry to do it. It waits for a section to finish
loading by counting spinners left in the frame, and counts elements
rather than visible ones — so when the AI card grew a "Warm up now"
button whose spinner sits beside its icon, rendered and hidden, waiting
for a click, the wait started looking for something that is never
removed. Ten sections, each spending the timeout on a frame that had
loaded perfectly. Asking for visible spinners takes that test from a
120-second failure to 4.7 seconds passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cutting the docker build to release tags stopped the `main` image tag
from ever moving again — nothing rebuilt it, and a tag build can't apply
a branch tag — so the dev-tip deployment that follows `main` froze at the
last pre-change build. Restore the push-to-main build, but choose the
platform matrix by event: a `main` push builds amd64 only (the dev-tip
deployment is amd64), a `v*` tag still builds amd64+arm64 for the release
image. Fresh dev tip without paying for arm64 on every push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docker.yml built the two-arch image on every main push AND every tag — and a
release is a main push followed seconds later by a tag, so the main-push image
was discarded by the tag build. It also rebuilt on every pull request, where
e2e.yml already builds the same image to test it. Both duplicates are gone;
a version and 'latest' are still published per release tag, workflow_dispatch
covers an ad-hoc build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first real run got through the image build, composer install, stack
boot and browser install, then died twice on the same rule: Docker creates
a missing bind-mount source, and every missing parent of it, as root.
~/.cache/composer is mounted into the composer container. On a cache miss
nothing had created it, so Docker created ~/.cache as root as well — and
`npx playwright install` then failed with EACCES on mkdir of a SIBLING,
~/.cache/ms-playwright. The composer cache itself was fine; the parent was
not.
var/ and public/assets/ are where compose.test.yaml mounts its named
volumes, so Docker creates them in the checkout as mount points, empty and
root-owned. `mkdir -p var/log` in the log-collection step then failed,
which would have cost the artifact on an otherwise green run.
One mkdir before anything containerised runs fixes both. Preferred to
`--user $(id -u)` on the one-off containers: the image's PHP tooling
expects root, and root-owned FILES were never the problem — tar reads
mode-644 files it does not own, so the artifact upload and the cache save
were always safe. Only creating a directory inside a root-owned one
failed, and Docker never creates a directory that already exists.
vendor/ and assets/vendor/ are deliberately left root-owned: no
runner-user step reads or writes either.
Verified locally on an unprivileged user, both directions. Without the
step, `mkdir -p var/log` reproduces CI's exact error; with it, var/,
var/log and public/assets survive a full `compose up` runner-owned, the
log step writes a tar-readable e2e-server.log, and ~/.cache/ms-playwright
can be created after the composer container has run.
CI served the browser suite with `php -S` on the runner, behind a router
script written for the occasion, with PHP and its extensions installed
beside it. Every accommodation in that workflow existed because of that
server and not because of the app: a php.ini copied into the scan dir with
sudo, PHP_CLI_SERVER_WORKERS, a router so index.php did not answer 56 asset
requests with 56 kernel boots, a copy of the environment into $_SERVER
because the cli-server SAPI provides none, and finally a restart loop
because the thing segfaulted mid-suite. A green run proved something about
a server no user will ever meet, while PHPUnit ran bare on the runner in
the same job that every developer ran in a container.
It now boots compose.test.yaml — the app's own image, from this
repository's Dockerfile — and runs `npm run test:unit:docker` and the
Playwright suite against it. Local and CI are one invocation.
Three things had to be true first.
app-init could not bootstrap a checkout that had never seen composer. It
overrides the image entrypoint to get the ordering right around the
database, and that entrypoint is also what installs dependencies into the
bind mount, so a fresh clone — or a worktree, or a CI runner — died on
"Dependencies are missing" before the stack came up at all. It installs
them itself now when vendor/ is absent.
admin-queue.spec.ts and mercure.spec.ts each carried a private copy of
`docker compose -f compose.test.yaml` with no -p, so both silently
addressed the DEFAULT project. Point the suite at a stack under any other
project name and the queue seed populated a database the browser was not
looking at, while the hub stop that mercure.spec.ts uses to prove the
stream recovers stopped a project that did not exist — which compose
reports as success. That is the whole of why admin-queue was believed to
need a running worker: it never did. `delivered_at` is what "a worker is
holding it" means to the transport, and app:test:seed-queue writes it.
Both now go through E2E_COMPOSE, resolved once in playwright.config.ts.
The image needed a stable tag so CI could build it ahead of time with a
layer cache, which is why compose.test.yaml names it rather than leaving
it to `<project>-<service>`.
Deleted: the webServer block, tests/e2e/support/router.php, the Postgres
service container, setup-php and its extension cache, the php.ini scan-dir
step, and the separate migrate/seed/asset steps the stack already does.
The server log still goes up with the report — from `docker compose logs`
now, and covering app-init and Postgres too, because when the failure is
that the stack never came up that is the only place the reason exists.
Verified against a compose stack from this worktree: PHPStan clean,
PHPUnit 2820 tests / 6979 assertions green, Playwright 253 passed and
0 failed — admin-queue and both mercure recovery tests among them.
You were right, and I had it backwards. I left the burger off the landing
page on the grounds that it has no sidebar to open — but .topbar-links are
hidden at that width too, so the front page offered no navigation at all.
Somebody arriving from a link had a page and no way anywhere else. It
needs the drawer MORE than a handbook page does, not less.
The drawer is on every page now, carrying the handbook navigation plus the
bar's own links, and it is hidden above the breakpoint where the sidebar
would have no business sitting beside a hero.
That hiding is scoped to a min-width query rather than left to source
order. Written as a plain rule it sat after the narrow block, matched at
every width and won on order — so the first version of this put a burger
in the phone's markup that nothing could click, which is exactly the bug
being fixed, one layer down.
On the horizontal scrolling: the document itself does not pan, at 320,
390 or 393. What moves is a <pre>, which scrolls inside its own box by
design — a wrapped shell command is a command somebody pastes wrong. But
`overflow-x` was `visible`, so anything that overflowed in future WOULD
take the page with it. It is `clip` now, not `hidden`: hidden would make
the element a scroll container and silently break the sticky top bar,
which is the thing the fix was protecting.
And the image build skips documentation. A handbook page, a translation
or a change to the site tooling cannot alter the image, and rebuilding it
for two architectures to publish identical bytes is the most expensive way
to do nothing. CHANGELOG.md is deliberately not in the ignore list — a
release commit always touches it, so the release path can never be
filtered out by a rule aimed at prose — and workflow_dispatch is there for
the case that reasoning misses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E2E is the expensive workflow here — a Postgres service, PHP with eight
extensions, a Chromium download, an asset build, PHPStan, PHPUnit and
around two hundred browser tests — and it ran on every push. Most of that
capacity went on proving the same thing repeatedly about commits nobody
was releasing.
It now runs on a `v*` tag, on a pull request against main, and on demand
through workflow_dispatch. Pull requests are kept because that is somebody
proposing a change, which is exactly when a second opinion is worth
having, and there are few of them.
**The trade is real and is written down in both places rather than left
to be discovered.** `main` is no longer verified commit by commit: a
regression pushed there is found when a version is tagged, possibly
several commits later, and the tag is then what fails. Running the suites
locally before pushing is the answer, and it is faster than waiting for a
runner in any case.
One sharp edge, now stated in CONTRIBUTING: a tag starts this workflow and
the image build in parallel, so a failing suite does not stop the image
being published. Tag from a tree the suites have already passed on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The picker was not the bug; the navigation was.** Every sidebar link on
a German page was built against the English path, so choosing German
lasted exactly one click and then put you back in English. The sidebar
and the Handbook link now follow the page's own language.
**And the choice survives arriving somewhere else.** It is remembered, so
a bookmark, a link from somebody else or a fresh visit lands in the
language you picked rather than resetting to English every time. The
redirect cannot loop — it lands where the page's language already equals
the remembered one — and it cannot strand anybody, because choosing
English is what sets the memory to English. The landing page is exempt:
following the memory there turned every visit to the site into a redirect
into the handbook, which is a redirect away from the page somebody asked
for rather than a language preference.
tests/e2e/site.spec.ts covers all of it — the two bugs you found by
clicking, plus the three ways the fix could go wrong. It is opt-in behind
E2E_SITE (`npm run test:e2e:site`) and does not run in CI: it needs a PHP
build and eight browser pages, and what it guards is a static site whose
failure is visible the moment anybody opens it.
**Publishing is faster.** The Pages job ran composer.json's auto-scripts
on every docs change — cache:clear, assets:install and importmap:install,
which is a Symfony kernel boot and a set of CDN downloads for JavaScript,
to render Markdown. --no-scripts, plus a download cache. The wiki job
needs no Composer at all and never did: bin/mirror-wiki.php has no
dependencies, which is now said out loud so nobody adds one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wiki is where somebody already inside GitHub looks. It has no way to be
the thing a link to plMail shows a person who has never seen it, so there
is now a site as well: a landing page and the whole handbook, at
karatektus.github.io/pl_mail, deployed by .github/workflows/pages.yml on
every push to main.
It holds no content. The landing page IS README.md and the handbook IS
docs/, rendered into a shell by bin/build-site.php — nothing on the site
can say something the repository does not, because there is nowhere for
it to be written down. Even the hero is lifted rather than written: its
lead is README's own opening paragraph, because a hero composed here
would be the one piece of prose no file backs and the first thing to go
stale. The wiki mirror stays; two outputs of one source cannot disagree
with each other.
The tree is preserved rather than flattened, unlike the wiki mirror,
which had to flatten because a wiki has one namespace. So a cross-link
between two pages needs only its extension changed and an image reference
needs nothing, and the rewriting that remains is the small honest kind: a
path that escapes docs/ becomes a link into GitHub, because the site
carries the handbook and not the source.
**The theme picker is generated from the app's own enum.** Theme::swatch()
already carries surface, ink and accent per theme for the appearance
picker, and the rest of a palette is derived from those at build time —
so the site offers exactly the seven plMail offers, and adding an eighth
to the app adds it here. Transcribing them would have agreed until
somebody added one, and then the site would quietly offer six of seven.
The language picker lists a locale as available when docs/<locale>/
exists and as "not translated yet" when it does not, which makes
translating the handbook the act of creating that directory and nothing
else. Today that means English, with German and Pirate shown and
disabled — a switcher that silently omitted them would say the site has
no opinion about German, where this says where the gap is.
Search is the whole handbook as 140KB of JSON, fetched on first focus and
matched in the browser. A hosted index would be faster to write and would
stop the site being a directory of files that works from a checkout, from
a fork's Pages, and behind whatever proxy somebody puts it behind.
league/commonmark is a require-dev dependency: the site is built in CI and
never by the application, so a Markdown parser in the runtime image would
be a package nothing runs. Bumped guzzlehttp/guzzle to 7.15.2 while
composer was open — 7.15.1 carries two advisories, one of which is a
noncanonical host bypassing host-based checks, which is the shape of the
SSRF guard.
Also corrects this file's claim that the non-Docker Playwright variants
start `symfony serve`. They have not since that server was replaced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.
The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.
Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.
Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.
compose.yaml mounted no volume for var/attachments, var/raw
or var/uploads. Workers write those during sync
and the web container serves them back, so each
kept its own copy in its own layer — downloads
404, and everything died on a recreate. The
override and the TrueNAS file both mount them;
the file the README tells an operator to run did
not. The same omission cost real data once
before. See the CHANGELOG for moving what is
currently stranded.
app:calendar:materialise new, nightly. Occurrences are drawn when an
event is saved and nothing moved that window
again, so a weekly standup ran out of dates in
two years and its reminders stopped with them —
silently. The query it runs had existed since
the materialiser did, documented as exactly this
sweep, and had never been called.
Microsoft wizard asked for three of the nine permissions plMail
requests, and a token already issued is not
upgraded when the rest are added later.
GmailWatchService told you to grant Pub/Sub Publisher to
gmail-api@system.gserviceaccount.com. The
account is gmail-api-push@; a topic granted
exactly what the docblock asked for never
delivered a notification.
IcsExporter said it wrote no VALARM. It has written them
since alerts landed.
CLIENT_DEVELOPMENT.md said there was no JMAP calendar API and that
SearchSnippet/get was absent. Both shipped.
CONTRIBUTING said only app:calendar:push registers a push
channel; subscribing has done it since the
subscribe flow learned to.
Three docblocks named getters removed long ago, or described a
method two below the one they sat on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 Playwright tests failed on CI and none of them were broken. The
Symfony CLI detects running Docker containers and injects its own
environment into the app it serves, overriding DATABASE_URL even when one
is explicitly set. On CI the Postgres service is a container, so the URL
the workflow sets was replaced with credentials nothing accepts, the app
could authenticate nobody, the per-worker login fixture died, and every
test in that worker fell over behind it. PHPUnit and every bin/console
step passed in the same job, because none of them go through the CLI.
DATABASE_URL=postgresql://app:app@127.0.0.1:5432/app \
symfony php -r 'echo getenv("DATABASE_URL");'
SYMFONY_SKIP_DOCKER_COMPOSE, SYMFONY_SKIP_DOCKER_SERVICES and
SYMFONY_DOCKER_ENV were all tried; none of them stop it, and there is no
--no-docker flag. So the suite now starts PHP's own server, which has no
detection to disable. The CLI install goes with it: it was there for
`symfony serve` alone.
Two things that server needs and does not do by itself. Naming
public/index.php as the router makes index.php answer everything,
including the 56 compiled assets /login pulls in — each one a kernel boot
returning 404 — so tests/e2e/support/router.php returns false for real
files and lets the server serve them off disk. And the cli-server SAPI
builds $_SERVER from the HTTP request and stops: getenv() answers,
$_SERVER does not, and Dotenv reads $_SERVER. Without the bridge in that
router, DATABASE_URL would silently revert to the committed default in
.env — the same failure as above arriving by a different road. HTTP_* is
excluded from the bridge, because $_SERVER['HTTP_PROXY'] is
indistinguishable from a Proxy header once a Request is built.
The server's log is redirected to a file and uploaded with the report
rather than piped to the console: it writes three lines per request, and
one slice of the suite produced 67,709 of them, which is how a real
failure gets buried. It is still the only place a PHP fatal that never
reached the kernel is recorded, so it is kept rather than silenced.
One onboarding test now retries opening the user menu. The menu is opened
by a Stimulus controller, and Playwright's actionability checks say
nothing about whether a controller has connected — the button is visible
in the server's HTML long before the module graph that animates it
arrives, and a click that lands in that gap is simply lost. FrankenPHP
served those 56 assets fast enough to hide it; this server does not.
Verified against a CI-shaped stack — host PHP, Postgres in a container,
the workflow's env block, this config starting the server: 185 passed,
14 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migrations run on boot and `latest` moves, so "is this the build I think
it is?" is a question with real consequences and no cheap answer. The
release and the commit now sit in the admin header — in the header
rather than in a panel, because it is asked while looking at something
else.
Stamped at image build and never discovered at runtime. A container has
no .git: the source is copied in and the history stays behind, so
anything that shelled out to git in production would answer from a
mounted volume or from nothing. The release workflow passes both values
from the metadata action it already computes; a plain `docker build`
passes neither, and an image nobody stamped says "development" rather
than guessing, which is also why the chip is absent entirely on a
checkout — a permanent "development" badge is furniture.
There is deliberately no test that a real checkout describes itself.
That would assert a property of wherever the suite runs rather than of
the class: the test container has the source but not the history, and
where it has both, git refuses the directory for dubious ownership — so
it passes on a laptop, fails in CI, and says nothing either way. This
suite has been bitten by that exact shape twice already (an id sequence
gap that only existed on a well-used database, a sidebar state that only
existed after another spec ran). The contract is tested instead: an
unreadable repository means "development".
config/reference.php is gitignored and untracked. Symfony regenerates it
on every cache warm-up and not deterministically — two keys inside one
@psalm-type block swap places — so a tracked copy showed a phantom diff
after any command that booted the kernel, all session. Its own header
says it is auto-generated and for apps only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit copied 10-app.ini into the scan dir reported by
`php -r PHP_CONFIG_FILE_SCAN_DIR`, which is the CLI's. The step passed
and changed nothing: `symfony serve` switches to PHP-FPM as soon as it
finds public/index.php, and the CI log confirms it —
[Web Server] PHP listening path="/usr/sbin/php-fpm8.4"
so the requests under test were served by a SAPI reading
/etc/php/8.4/fpm/conf.d, not the cli one. The 3MB upload test went on
failing at stock 2M.
Symfony CLI's documented per-project override is a php.ini at the
project root, which it hands to FPM. Measured in an ondrej-PPA
container, serving through symfony serve exactly as CI does:
baseline sapi=fpm-fcgi upload=2M
cli scan dir sapi=fpm-fcgi upload=2M <- the previous commit
project php.ini sapi=fpm-fcgi upload=25M
fpm scan dir sapi=fpm-fcgi upload=25M
Both copies are kept, because both SAPIs are in play: FPM serves the
browser suite, and the CLI runs PHPStan and PHPUnit. The CLI needs its
own, since that SAPI deliberately ignores a php.ini in the working
directory — verified rather than assumed.
php.ini is gitignored: CI writes it, and a committed one would quietly
override these limits for everyone from a file nothing points at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose suite has a test that attaches a 3MB file, and it has never
passed on GitHub Actions — not since the commit that introduced it.
frankenphp/conf.d/10-app.ini is what lifts PHP's stock 2M/8M upload
limits to 25M/60M, and it reaches PHP through a COPY in the Dockerfile.
CI never builds that image: it serves through `symfony serve` against
setup-php's interpreter, which loaded none of it. So the runner sat at
upload_max_filesize=2M, PHP refused the 3MB upload before the request
reached Symfony, and the attachment chip the test waits for was never
rendered. e7b9ac8 added the test and the ini bump together, which is
why it looked like it should work — the two halves just landed in
environments that do not overlap.
Copying the file rather than restating the values in `ini-values` keeps
one source for both environments, and covers every other setting in it
for the next time one of them starts mattering to a test.
Verified by forcing a container back to 2M: same locator, same failure,
same artifact path as the CI run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.nvmrc said 22, the CI workflow pinned 20, and the machine the browser
suite was actually developed and run on had 24. Three numbers, none of
them agreeing, which means "passes locally, fails in CI" had three
plausible explanations before anyone looked at the code.
.nvmrc is now the single source — CI reads it with node-version-file —
and it says 24, which is what the suite has been passing on.
package.json's engines floor stays at >=18: that is a genuine minimum
rather than a choice, and there is no reason to raise it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
latest followed the default branch, so every push to main went straight
to the tag the README tells people to pull — including work in progress,
and migrations that run automatically on boot. It now moves only on a v*
tag; main still publishes under its own name for anyone wanting the tip.
The roadmap listed three finished things as open, including the label
refactor and the nested label UI, both of which shipped. A roadmap that
under-reports is worse than none: it is the document a contributor reads
to decide what to pick up. Corrected, with the genuinely open items
described accurately and a note asking that it be kept honest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
55k lines of PHP with no static analysis, and a dependency tree holding
four OAuth clients, a JWT library and an IMAP parser with nothing
watching it for advisories.
PHPStan at level 5 with a baseline. Level 5 is where the class of defect
this codebase actually has starts showing up — nullable entity getters
flowing into non-nullable parameters — without a fortnight of triage
first. 208 findings are baselined as a debt ledger, not a licence; the
level goes up when the ledger is small enough that raising it is a day's
work.
Two of the 217 were real bugs, both fixed here rather than baselined:
"Test connection" in account settings constructed ConnectionTestResult
with four arguments where six are required, and passed null for a string
parameter. Both call sites are the early returns for an incomplete form
and a blank password — that is, the two cases the button exists to
report, each of which raised an ArgumentCountError instead.
GraphCategorySyncer caught App\Service\Mail\GraphApiException, which does
not exist; the client throws App\Domain\Exception\GraphApiException. The
catch never matched, so the 403 that means "this account needs
reconnecting with MailboxSettings.ReadWrite" propagated as an unhandled
error instead of degrading quietly, which is what the comment inside it
says it is there to do.
One more it found is left alone deliberately, because it needs a decision
rather than a fix: Controller/Admin/User/DefaultController references
App\Form\Admin\UserFormType and User::setName(), neither of which exists,
so that admin page fatals on load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>