The armhf snap failed all three attempts in v10.77 while s390x, ppc64el
and riscv64 each built on attempt 1. The Launchpad build log says why,
one line into the mongodb part:
Stage package not found in part 'mongodb': libcurl4.
Build failed
Ubuntu 24.04's 64-bit time_t transition renamed the library, and
`libcurl4` is not a real package on ANY architecture in noble - the
binary is `libcurl4t64` everywhere. On the 64-bit architectures the
renamed package keeps the old name alive:
Package: libcurl4t64
Architecture: amd64
Provides: libcurl4 (= 8.5.0-2ubuntu10)
and the armhf libcurl4t64 has no Provides line at all, because there the
ABI really changed. That is what makes this class of bug reach exactly
one architecture, which is also what makes it look like flakiness on
that architecture.
It had already happened, to libssl3 and libgoogle-perftools4 in v10.71;
the comment above this list describes it exactly. libcurl4 was the same
transition and was left behind. Every other stage-package was checked
against the noble armhf main+universe indices at the same time - all 14
of them - and libcurl4 was the only one that did not resolve.
The workflow's own guidance made it worse. snapcraft ends a missing
stage-package as a "Stopped" build with no artifact, which from the
outside is indistinguishable from Launchpad cancelling a build, so the
message said "often an OOM in the Meteor npm install, or a transient
build-farm reset ... re-run, and if it persists on this arch open a
Launchpad question" - the wrong advice for a failure that will never
succeed on a re-run. The step now looks for the line first, names the
package that is missing, says it is NOT transient, explains the t64
Provides asymmetry, and gives the one-line archive query that checks a
name.
New test tests/snapStagePackages.test.cjs, 6 checks: no stage-package is
a known pre-t64 name, libcurl is asked for by the name noble publishes,
the two fixed in v10.71 have not regressed, and - negatively - a name
that was never renamed keeps its plain name, because the rule is not
"add t64 to everything". It also pins that the reason stays written down
beside the list. Verified it fails when libcurl4 is put back.
Thanks to xet7 !
Four things the v10.77 run got wrong, all of them silent - nothing
errored, the reports were simply not what they claimed to be.
THREE GOOD SNAPS WERE REPORTED AS FAILED. s390x, ppc64el and riscv64
each printed "Remote build <arch> succeeded on attempt 1" and downloaded
their .snap, and each job then ended FAILED - because the step that
SAVES the build log could not upload it:
The path for one of the files in artifact is not valid:
/snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt.
Contains the following character: Colon :
snapcraft names a remote-build log after the recipe and an ISO
timestamp, and upload-artifact refuses a colon because NTFS cannot hold
one. The colons become dashes now, so the timestamp is kept rather than
the name thrown away - and the upload is continue-on-error, because a
DIAGNOSTIC upload must never be able to fail the build it is diagnosing.
The snap is the deliverable; the renaming is not the last thing that
could ever make a log file unuploadable.
EVERY ROW OF THE PROVENANCE TABLE APPEARED TWICE. provenance-table.sh
globbed `provenance/**/*.tsv provenance/*.tsv`, and with globstar `**/`
matches ZERO or more directories, so the first pattern already covered
the second and every file was read twice. It is one pattern now, and
rows are also deduplicated on the WHOLE line - a retried step or a job
that ran again records an identical line, and nothing tells the copies
apart. Deliberately not `sort -u -k1,1 -k2,2`: two rows sharing a bundle
and a binary but differing elsewhere are not a duplicate, they are a
real disagreement about which Node.js went into a bundle, and hiding one
at random is worse than showing both.
AMD64 WAS MISSING FROM THAT TABLE - the platform most people download.
Its step runs `cd .build` before `mkdir -p provenance`, so the rows went
to .build/provenance while upload-artifact looked at provenance/ from
the workspace root and found nothing: "No files were found with the
provided path: provenance/". Every other build job records from the
workspace root, which is why only this one was affected. It uses
$GITHUB_WORKSPACE now.
SIX PLATFORMS SAID VERSION "LATEST". amd64, arm64, win64, win32,
mac-arm64 and mac-x64 passed the literal string; only the
extra-architecture job asked which version `latest` actually was, and it
did so with its own inline curl+python. "Which FerretDB did v10.77 ship"
is the one question that column exists to answer. All seven sites now
call the new releases/ferretdb-latest-tag.sh, which asks once per job
and caches, authenticates when there is a token so a shared 60/hour
limit is not what makes it fail, checks that the answer is shaped like a
tag before printing it into a markdown cell, and prints NOTHING and
exits 0 when it cannot find out - the caller's ${FERRET_TAG:-latest} is
then exactly what it had before. A release note must never fail a build
that produced a good bundle.
The table's own prose linked [wekan/node](https://github.com/wekan/node);
the binaries come from wekan/node-patches, which is what the rows
themselves already linked.
New test tests/provenanceTable.test.cjs, 20 checks, most of them running
the scripts rather than reading them. Verified in both directions: with
the doubled glob restored the glob guard fails, with amd64's relative
path restored its guard fails, and with the literal 'latest' restored
its guard fails.
Thanks to xet7 !
WeKan ships binaries other projects publish - FerretDB, the MongoDB
Database Tools, Node.js - and fetches each from a release URL. There are
many such URLs, across workflows, release scripts and compose files, and
they must all agree: one that names a fixed version stops receiving
security fixes silently, and nothing about the build fails when it does.
That is not hypothetical. A Quay scan of the v10.77 image reported Go
advisories in the FerretDB binary baked into the bundle - stdlib 1.25.9
wanting 1.25.11, golang.org/x/sys v0.38.0 wanting 0.44.0. The source was
already fixed and v1.48.0 was already published carrying go1.25.11 and
x/sys v0.46.0; the image had simply captured an older latest at build
time. Had any of these URLs been pinned instead, the rebuild would not
have fixed it either.
The test finds the URLs by searching the repository rather than from a
list, so a build site added later is checked too, and it accepts three
shapes: latest/download/<asset>, the bare latest API endpoint that
release-all.yml asks which version latest resolved to for the provenance
table, and ${VAR} whose default is checked separately to be latest.
Node.js is a different mechanism with the same effect - NODE_VERSION is
the bare major 24 and resolve-node-source.sh answers with that CPU's
newest 24.x from whichever of nodejs.org, unofficial-builds or
wekan/node-patches has one - so it is asserted to stay that way.
Thanks to xet7 !
BuildKit attaches MINIMAL provenance by default - a build id and a timestamp -
which is what those `unknown on unknown` rows on quay.io are: the OCI marker for
"not a runnable image", one per platform, so `docker pull` never selects them.
mode=max records what actually answers a supply-chain question instead: which
commit, which base image, which build arguments.
WeKan pushes images from FOUR places - docker-publish.yml's build-push-action,
two `docker buildx build --push` in release-all.yml, and one in
releases/docker-build.sh - and none of them said anything about provenance, so
all four took the default. All four ask for mode=max now. The local `--load`
check build in docker-build.sh deliberately does not: the docker exporter cannot
carry an attestation at all.
SBOM stays off, and the test says why so it is a decision rather than a drift:
it enumerates every OS package and npm dependency, taking the attestation from
~10 KiB per platform to megabytes.
tests/dockerProvenance.test.cjs finds the build sites by SEARCHING for them
rather than from a list, so a fifth one cannot quietly fall back to the default.
It also pins a trap I fell into twice while writing this, once in the shell
script and once in a YAML `run:` block:
docker buildx build \
# explain the next flag
--provenance=mode=max \
--platform ... \
A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
that is `docker buildx build` with every argument swallowed - no platforms, no
tags, no push. `bash -n` accepts it, because it is valid syntax; it is simply a
different command. Both comments live above their command now, and the test
fails on any comment sitting among a continued command's arguments - while
ignoring a comment block that merely quotes a multi-line example, which is what
it flagged first.
Thanks to xet7 !
Same group, because it is the same area and the same one-line cause - a gh call
without --repo in a job that does not check out. The subsection is 'bugs' now
rather than 'bug'.
Thanks to xet7 !
The same one-line bug AppImage.yml had, and a second one beside it.
gh release upload 'v10.77' assets/* --clobber
failed to run git: fatal: not a git repository
Both flatpaks built and were uploaded as artifacts; the job that attaches them
does not check the repository out - it only downloads artifacts - so `gh` had no
git remote to infer the repository from. Every gh call in this workflow passes
--repo now, including the ones in jobs that DO check out.
The second: `assets/*` is not the bundles. The artifacts also carry the ostree
repository the flatpak was exported through - config, objects/, refs/,
summaries/, summary, summary.idx - which is build scaffolding, and attaching it
to a release would have put a few hundred directories there beside the two
files anyone wants. The upload names the bundles and their checksums instead.
TSC hit this exact thing and fixed it the same way.
No architectures to add, and the header now says why rather than leaving it to
be rediscovered. A flatpak runs against a RUNTIME, so an architecture exists
here only if freedesktop.org publishes org.freedesktop.Platform for it: x86_64
and aarch64, the i386 and arm runtimes having been discontinued. That is the
difference from AppImage.yml, which could grow to four - an AppImage carries its
own runtime binary, and those exist for i686 and armhf too.
Thanks to xet7 !
v10.77 was cut while they were being written, so they get their own Upcoming
above the release, and the links point at the hashes the rebase onto the version
bump produced rather than the ones that will be collected.
Thanks to xet7 !
x86_64 and aarch64 become four, with i686 and armhf. That is the whole set: an
AppImage BEGINS with a runtime binary the kernel executes, and runtimes exist
for exactly x86_64, aarch64, i686 and armhf. ppc64le, s390x, riscv64 and loong64
have none, so they cannot have an AppImage at all however the job is written -
they are served by the bundle zip and the .deb, and the matrix now says so where
somebody would otherwise try.
The two are not equally safe, and they are not treated as though they were.
i686 builds on the x86_64 runner, which runs 32-bit x86 natively: no emulation,
no multiarch, and appimagetool-i686 runs as-is. It is an ordinary job.
armhf builds on the aarch64 runner, which runs 32-bit ARM only if the kernel has
CONFIG_COMPAT and the 32-bit loader is installed. GitHub promises neither, so it
is continue-on-error - the same treatment TSC gives its emulated armhf job. If
it proves reliable, drop the flag; if it never works, drop the entry. What it
must not do is decide whether the other three reach the release.
The smoke test needed the distinction too. It starts the AppImage and waits for
WeKan to answer on port 8080, which a 64-bit runner cannot do for a 32-bit build
it cannot execute. A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK: when the
binary will not execute at all AND it is not this machine's architecture, the
AppImage is as good as anything here knows, so it warns, says it is uploaded
unchecked, and moves on. A build that DOES start and then does not answer still
fails - that is the bug the step exists for.
The publish job's two hardcoded architecture lists learned all four, so a
missing one warns instead of going unnoticed, and the base-bundle check looks
for wekan-<v>-{i386,armhf}.zip as well.
Untested here: this is a workflow, so the first real run is the test. The two
new jobs are the only ones that can fail, and armhf cannot take the release with
it.
Thanks to xet7 !
Both AppImages built, both passed the smoke test that starts them and waits for
WeKan to answer on port 8080, both uploaded as artifacts, both downloaded by the
attach job with matching digests - and then nothing reached the release:
gh release upload 'v10.77' assets/* --clobber
failed to run git: fatal: not a git repository
The attach job does not check the repository out. It has no reason to - it only
downloads artifacts - so `gh` has no git remote to infer the repository from.
Every other gh call in this workflow already passes --repo; these two did not.
Every gh call passes it now, including the two in jobs that DO check out. A call
that relies on an inferred remote breaks the day somebody removes the checkout,
and the error it gives names neither gh nor the workflow.
Worth recording for whoever reads the failed run: the log looks far worse than
the bug. `::error::` lines appear in the build jobs' output for the bundle-file
check and the port-8080 check, and in the attach job for "no architecture
produced an AppImage" - none of which fired. They are the SCRIPT being echoed by
the ##[group] header, not output. The real failure is one line, in the last step
of the last job.
Thanks to xet7 !
The page listed five architectures and omitted armhf, which snapcraft.yaml has
built all along. It said the release publishes candidate, beta and edge and that
"the stable channel is published manually later" - which is no longer true and
was the reason a build reached three channels of four. And it said nothing about
the two architectures that are bundles but not snaps, so "missing" and "cannot
be there" looked identical.
The matrix is the six `build-for:` entries with the bundle name beside each,
because the two names are not always the same, and a new section explains the
three ways they differ:
ppc64le / ppc64el the same 64-bit POWER under two names - the bundles use
the kernel's, the store uses Debian's. The only rename.
armhf / armv7 NOT a rename, and getting this wrong ships a snap that
crashes. node-patches builds armhf to the Debian baseline
(hard-float, VFPv3-D16, no NEON assumed) so it runs on any
ARMv7-A, and armv7 with NEON for boards that have it. The
store has ONE 32-bit ARM architecture serving every such
device, so it must carry the BASELINE build; the NEON one
would be an illegal instruction on a board without NEON.
armv7 ships as a bundle only.
i386 cannot have a new snap at all, and this is categorically
different from a missing Node.js build. node-patches
patches SOURCE so a binary can be built; here the BASE SNAP
does not exist, because Ubuntu 24.04 has no i386 port. No
patch set can produce a base Canonical does not publish.
The last base with one was core18, end-of-life.
Then what to do about it: the three snaps, all four channels, and why stable is
possible - core24 is a released base, so the snap carries grade: stable, which a
grade: devel core26 snap cannot. A table of what each snap has in the store
today and what is still to upload, including the two fossils that channel
promotion cannot fix: wekan-ondra's armhf at 0.22 and its i386 at 0.X-ci, which
have no newer revision to promote.
And the caveat that matters when running it: without a pinned version the newest
revision of each architecture is promoted, and edge is often ahead of stable, so
a bare run publishes edge builds to stable users.
The armv7 note in models/lib/snapArchitectures.js said "different CPUs", which
was the right conclusion from the wrong reason. It now says what node-patches
actually builds, and a test cross-checks it against that repo's own workflow so
the explanation cannot drift from the binaries.
Thanks to xet7 !
The helper that did this released ONE snap, ONE revision, to THREE channels:
snapcraft release wekan $1 edge,beta,candidate
wrong three ways at once. It names only `wekan`, leaving wekan-ondra and
wekan-gantt-gpl to be done by hand. It takes ONE revision number, and revisions
are PER ARCHITECTURE - the store's own listing shows wekan at 3601 on amd64 and
3600 on arm64 for the same 10.76 - so one number can only ever be right for one
of them. And it leaves out `stable`, so a build reached three channels of four
and somebody had to remember the fourth.
releases/snap-release-all-channels.sh resolves the revision per (snap,
architecture) from the store itself, so no revision number is ever typed, and
releases it to all four channels in ONE call - a revision reaches all of them or
none. A (snap, architecture) with no revision is reported and skipped rather
than failing the run: the three snaps genuinely have different architecture
sets, and one architecture failing must not stop the rest.
THE MAPPING IS THE HAZARD, and it is now in one place with both directions
tested (models/lib/snapArchitectures.js):
ppc64le and ppc64el ARE the same hardware. The bundles at
https://github.com/wekan/wekan name it the way Node.js and the kernel do; the
Snap Store names it the way Debian does. Nothing warns when the wrong one is
used - an unrecognised --arch is simply an architecture the store has never
heard of, so it looks like it worked.
armhf and armv7 are NOT. armhf is 32-bit Raspberry Pi OS; armv7 is the
ODroid-U3. Different CPUs, different bundles. The store has ONE 32-bit ARM
architecture, armhf, so that is what is published; mapping armv7 onto it would
hand Raspberry Pi users the ODroid build. The negative test for that is the
most important one in the suite.
The six architectures are exactly snapcraft.yaml's `build-for` list, and a test
compares the two so the table cannot drift from what is actually built. i386 and
armv7 are recorded as not-built, with their reasons, and the script prints them
on every run - "it is missing" and "it cannot be there" look identical from the
outside.
tests/snapArchitectures.test.cjs: 23 checks including that the newest revision
for THAT architecture wins, that a version filter publishes what was built
rather than whatever was uploaded last, that `snapcraft list-revisions` parses
when a revision has no channels, and that junk never reaches snapcraft.
Thanks to xet7 !
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.
Thanks to xet7 !
v10.75 was cut while they were being written, so they get their own Upcoming
above the release. The FerretDB one is summarised here and told in full in that
repo's CHANGELOG, since it is the database that changed.
Thanks to xet7 !
Neither of these gave a wrong answer. They asked the right question one document
at a time, and each await is a full round-trip that starts only once the last
one finished - invisible on a board with four cards, and the whole cost of the
request on a real one.
The bulk label endpoint (PUT .../cards/labels) awaited a getCard per id and then
an update per id. At BULK_CARDS_MAX = 500 that is ONE THOUSAND sequential
round-trips for a single request. The reads are all the same question, so they
are one `$in` query indexed into a Map; the writes genuinely differ - each card
merges its own labelIds - so they stay individual updates, but they are issued
together and awaited once instead of each waiting for the last.
Two details that had to survive: the loop still iterates cardIds rather than the
query result, because a batch read comes back in the database's order and
without the ids that matched nothing - iterating it would silently reorder
`updated` and lose `notFound`. And every write is awaited before the 200 is
sent, so the response still means what it says.
The bulk DELETE beside it is deliberately left alone: its per-card work is real
(cardRemover runs the sub-item hooks, each card gets its own activity), so
batching the reads would save one query inside a loop that does far more than
query.
Global search resolved each `user:`/`member:`/`assignee:`/`creator:` name with
its own awaited findOne, so `member:ann member:bob member:carol` was three
serial round-trips before the search itself could start. They are all the same
question - which of these names is an account - so it is asked once, with `$in`,
and answered from a map. A name typed under two operators is one lookup now, not
two. Unknown names are still reported against the operator they were typed
under, because "ann is not a user" is not useful without saying where ann was
typed.
tests/queryBatching.test.cjs pins both, including the negatives: no per-card
read remains in the label handler, no per-username query remains in search, the
response order and notFound list are preserved, and the delete handler is still
per-card on purpose.
Thanks to xet7 !
The release script renamed the old Upcoming to v10.74, and two commits landed
after it: the linked-card cache that could not see a card added later, and the
build-script parity fix. They get a fresh Upcoming section above the release,
which is where development entries go.
Both were rebased onto the version bump, so their hashes changed; the links
point at the ones that will be pushed, not the ones that will be collected.
Thanks to xet7 !
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: publishComposite re-runs a child's find() when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.
The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason.
Thanks to xet7 !
The one red suite in the test run, and it was right twice over.
`Setup: "git pull" and "git push" that finish the job` added two entries to
build.sh's menu and left build.bat behind, so `build.bat git-pull` answered
nothing. It answers to both names now, with its own :gitpull/:gitpush labels -
the ones its interactive menu already used.
The guard also had to learn what a `!` entry is. It marks something build.sh
runs ITSELF rather than by executing a file in releases/. Most are raw commands
and the .bat runs those the same way, so those still compare as before; the
exception is an entry naming a build.sh FUNCTION, which is shell the .bat has no
way to call. Comparing those as scripts is what made this suite fail the moment
"Update git" was replaced. A function entry is now exempt from the script
comparison and checked separately: build.bat must implement a label of the same
name, so one dropping out of a menu still fails.
The node suites are 339 run, 0 failed for the first time in this branch.
Thanks to xet7 !
The five linked-card cursors are its own commit, and its story is told inside
the ParentBleed entry - so a reader could read about the fix and had no way to
reach it.
Thanks to xet7 !
Six dependabot bumps and two developer-facing commits since v10.73 had no entry
at all. Adding them showed the subsections were also out of order - fixes came
before features - so they are now CRITICAL SECURITY, features, dependencies,
bug fixes, developer-facing, which is what CLAUDE.md specifies.
Thanks to dependabot and xet7 !
The page was doing its job and nobody had acted on it. Two real WeKan bugs were
sitting there as rows marked "unknown / unclassified", whose advice was to add a
rule to models/lib/databaseErrors.js. Both are fixed, and both now classify.
1. moveSwimlane, four times over a week:
Error: update is not available on the server. Please use updateAsync()
Board.ensureDefaultSwimlaneId() called the SYNCHRONOUS Swimlanes.upsert().
Meteor 3 removed it on the server, so every move that reached the default
swimlane self-heal threw. The async twin already existed one function below.
This getter is synchronous and cannot await, so the upsert is now STARTED and
not waited for: the self-heal still happens, and the call returns what is in
the cache - which every caller already had to handle, because it was
undefined before the self-heal existed at all. A caller that needs the
swimlane back in the same tick uses ensureDefaultSwimlaneIdAsync().
2. moveList:
ValidationError: Failed validation
Cannot read properties of undefined (reading 'title')
List.move() inserted a new list with `title: this.title`. When a list has no
title, `title` is required by the schema, so the insert fails validation - and
collection2's own error formatter then reads a property of the undefined field
and throws, which is why the admin saw a crash naming neither the list nor the
real problem. It now says what is wrong before the insert: "This list has no
title, so it cannot be moved to another board. Give it a title first." The two
console.log lines that used to sit there printed the title to a log nobody
reads and did not stop the crash; they are gone.
And two classifier rules so neither reads as "unknown" again. Both say plainly
that this is WeKan's bug rather than the database's or the admin's - there is
nothing to configure - and send the reporter to the right place: upgrade, then
open an issue with the method name. Severity `warning`, the vocabulary this
stream uses.
Thanks to xet7 !
Attachments and avatars are files under WRITABLE_PATH, and the database holds one
document per file. NOTHING checked that the two still agree. A file can be
replaced, truncated, back-dated or deleted by anything that reaches the
filesystem - a bad restore, a sync tool, a container rebuild, a shell on the
volume - and WeKan would keep serving whatever is there now, silently.
A baseline is recorded per file in the existing WeKan database (no new files
under WRITABLE_PATH): path, size, modification time, and md5, sha256 AND sha512.
Three digests, because they answer different questions. md5 is what other tools
print, so an admin can compare against a backup with what they already have - it
is never alone, its collision resistance is gone. sha256 is the working digest.
sha512 earns its place on the day the two DISAGREE: two digests over the same
bytes cannot, so when they do, the bytes were not read the same way twice - a
failing disk, a partial write, a truncated copy - which is a different problem
from a substitution and is its own critical finding.
ed25519 is the fourth check and it is NOT a hash - it is a signature, and it
answers the one thing three digests cannot: who says these are the right hashes?
Anybody who can rewrite a file can rewrite a row of hashes. Each entry is signed
over a canonical line (fixed field order, no JSON, whose escaping is not
guaranteed stable) and verified on every scan. A record that does not match its
signature is critical and is NEVER explained away by an ordinary edit. The key
comes from WEKAN_INTEGRITY_PRIVATE_KEY, which WeKan never stores - that is the
configuration that means something against an attacker who reaches the database
too - or is generated and kept in the database, which detects filesystem-only
tampering and is honest about being no more than that. A MALFORMED supplied key
is reported rather than silently replaced by a generated one, which would look
like it was working while checking nothing the operator meant.
The scan is paced by policy, not by hope: once a day, checked hourly; nothing
starts or continues at or above 60% CPU; 50 ms between files plus 20 ms per
megabyte; a fifteen-minute budget, after which it stops and continues tomorrow;
and ONE read per file for all three digests. A run that stopped early does not
report the files it never reached as missing.
The finding is a change WITH NO RECORD SAYING WHY. Files change when people use
WeKan, and those changes are accounted for, reported once at info and
re-baselined so they do not repeat daily. A change with nothing to explain it is
at least medium and is deliberately NOT re-baselined, so it keeps showing until
somebody looks. A modification time that moved BACKWARDS is called out
separately: nothing does that by accident.
Crashes and downtime, in the same stream, because a crash leaves nothing behind:
the process is gone, so it cannot write a message about being gone. A heartbeat
every minute is the only thing that works, and the NEXT start reads it. A first
run and a clean stop record nothing - reporting every deliberate restart is how
a Problems page becomes one nobody opens. A short gap with no clean-shutdown
mark is a note; a long one is a high-severity "it stopped without shutting down
cleanly, and here is how long it was down". Uncaught exceptions and unhandled
rejections land here too, with their cause, before the process goes.
Admin Panel / Problems / Filesystem integrity is the new stream, with the
Username / IP address / Attempts columns. The baseline itself is never published
and never client-writable: it is a map of every file on the server, and the key
document is a private key.
tests/fileIntegrity.test.cjs, 34 checks: every digest case including same-size
substitution and disagreeing digests, the ed25519 forgery attempt, the canonical
line, explained vs unexplained, and the pacing - including that an unreadable
clock does not start a scan, which it did until the test said so.
Thanks to xet7 !
The security event log said what a guard DID - a request blocked, a filename
sanitized - which answers "is WeKan defending itself" and not the question an
admin asks next: who did that, and from where. It also could not tell a browser
that got confused from somebody working through the Hall of Fame one entry at a
time.
A CANARY is a tripwire at a point that only a permission-override attempt
reaches. Ordinary use never gets there - the code path runs when a request asks
for something the rules forbid - so a trip is not noise, and it is worth an
admin's attention with the actor attached. Design:
docs/Security/Remediation/WeKan.md §12.
Three properties, each enforced by a test:
SILENT. Tripping one changes NOTHING the caller can observe. tripCanary()
always returns false and tripCanaryDeny() always returns true, so a call site
reads as the refusal it replaces:
if (forbidden) return false; // before
if (forbidden) return tripCanary('card.vote-field', { userId });
The return value is decided before any reporting is attempted and the reporting
is wrapped, so a canary can never throw into, delay or disturb the request it
watches. The REST handler re-throws the ORIGINAL error object. This is not
politeness: a canary that announces itself is a map of which paths are watched,
and a probe would simply avoid them.
BOUNDED. A canary sits where an attacker can loop, so one row per attempt would
be a denial of service they get for free. models/lib/canaryTokens.js - pure, no
Meteor, no clock of its own - records the first trip of a (canary, actor) pair
at once, COUNTS the rest of the window, flushes one summary carrying the total
when the window closes, and stops writing after 60 summaries for a pair that
never gives up. The tracked-pair map is capped at 5000 and evicts the LEAST
RECENTLY seen, so a long-running attacker is not pushed out by a passing one and
a botnet varying its address cannot grow it. Idle pairs are swept on a timer. A
thousand attempts in a minute cost ONE row, and a suppressed trip costs one map
lookup. The username lookup is cached and capped, and neither it nor the insert
is awaited on the caller's path.
ATTRIBUTED. Every event carries userId, username, ip and count. The username is
denormalised at write time on purpose: it is what the account was CALLED when it
tried, so a rename does not rewrite history and a deletion does not erase it.
The address uses the same spoofing-safe resolver as the login throttle -
X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - or an
attacker could write a colleague's address into the security log by sending a
header. `detail` says what was attempted in the words of the feature, never the
payload.
Seventeen canaries are wired at the permission checks that refuse the attempts
behind BoardBleed, ParentBleed, ChecklistBleed, PathBleed and CommentBleed, plus
the direct vote/poker field writes and a stored path that points outside the
storage root. All server-side: no Playwright, no browser, nothing to install.
Admin Panel / Problems / Security gains Username, IP address and Attempts
columns, and both new columns are searchable - the thing an admin does with one
security event is pivot on it.
tests/canaryTokens.test.cjs (25 checks) drives the limiter through bursts, an
endless probe, eviction order and junk input; tests/canaryCoverage.test.cjs (18)
pins every canary to its call site and asserts none of them changes what the
caller gets back.
Thanks to xet7 !
The five linked-card cursors and the streaming attachment read join the
ParentBleed and PathBleed entries they extend; the comment-reaction ownership
fix and the security-test coverage guard get subsections of their own.
Thanks to Alpastx and xet7 !
"Are the security tests enough to prevent what is in the Hall of Fame" was
unanswerable. WeKan publishes 58 named vulnerabilities at
https://wekan.fi/hall-of-fame/ ; some suites name the one they belong to, most
did not, and the Hall of Fame lives in a different repository that CI never
checks out. Answering it meant reading 58 pages against 300 suites by hand,
which means it was never answered - and a regression test that quietly stops
existing is worth nothing, the failure mode tests/testsAreRegistered.test.cjs
already caught once when two *bleed suites drifted out of the mocha index.
So the list lives in the repository that has the tests, and
tests/securityRegressionCoverage.test.cjs keeps the two in step. Every published
vulnerability is either GUARDED - named by a suite that still exists - or
RECORDED, a gap with a written reason, which is the CHANGELOG's "TODO Later"
pattern applied to tests. The gap count is pinned, so a vulnerability cannot be
published with neither a test nor a note; a gap that turns out to be guarded
after all fails too, so coverage is never understated; and a file that merely
REGISTERS suites is not accepted as coverage, or the guard would pass itself.
Getting there meant naming vulnerabilities in the suites that already guarded
them, which is most of what this commit is:
- tests/securityMeifukun.test.cjs guards eight reports and named one. Its
sections are RedirectBleed, SourceBleed, LiveBleed, CasBleed, OidcBleed,
MetricsBleed, ImpersonateBleed and InviteBleed - seven vulnerabilities that
looked untested and were not.
- tests/noIdentityReplacement.test.cjs guards IdentityBleed and PatternBleed,
the same CodeQL finding twice.
- ExportBleed, CrashBleed, MimeBleed and the four LockoutBleed suites now say so.
- tests/crossBoardParentCardLeak.test.cjs also guards BoardBleed's cross-board
move deny, and now checks it on Lists and Swimlanes too, not only Cards.
The count that comes out of it: 29 of 58 published vulnerabilities have a named
regression test, and 29 are recorded gaps - mostly older fixes from before WeKan
tested its security fixes at all. They are not known to be unprotected; they are
known to be unchecked, which is a different and more honest statement, and each
one now says what it would take to close it.
Thanks to xet7 !
Found while auditing for more of GHSA-pqr4-rxgp-hv2m (CommentBleed - the REST
comment delete that checked board membership and nothing about WHOSE comment it
was). A CardCommentReactions document holds
{ cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }
and the whole array is ONE field, whose allow rule was board membership for
insert, update and remove alike. So any member could $set `reactions` to
anything: add a colleague's userId to a reaction they never made, or remove one
they did. `toggleReaction()` only ever touches Meteor.userId(), so no legitimate
client sends anything else - the rule simply never said so.
Integrity rather than confidentiality: reactions are visible to the whole board
already. But it puts words in another person's mouth, and it is the same shape
as the advisory - a per-user object gated only by "are you on this board".
A deny rule now refuses an update that changes any OTHER user's presence in any
reaction. The decision is pure and lives in models/lib/reactionOwnership.js:
it compares MEMBERSHIP rather than array order, because the client rebuilds the
array on every toggle and a reordered array with the same membership is the same
set of reactions. The modifier forms that cannot be checked that way -
$push/$pull/$addToSet/$unset, or a dotted `reactions.0.userIds` - are refused
outright, since toggleReaction() always $sets the whole array and letting them
through would be a hole the size of the one being closed.
The membership rule it sits on top of is unchanged: read-only and no-comment
members still may not react at all.
tests/reactionOwnership.test.cjs covers both directions - adding and removing my
own reaction, the first reaction and the last one going away, a reordered array,
several of my own toggled at once - and the attacks: adding somebody else,
deleting somebody else's, wiping an array that was not only mine, moving
somebody else between reactions, an anonymous caller, and junk in either array.
Thanks to xet7 !
GHSA-jvv9-498p-hxrg was reported against the ANCESTOR cursor of the `board`
publication - a cross-board `parentId` made it publish a private board's cards
to another board's subscribers. Auditing for more of the same found the
linked-card cursors beside it with the identical hole and a wider blast radius:
five of them, publishing the linked card, its comments, its attachments, its
checklists and its checklist items.
A `cardType-linkedCard` names a card by id exactly as `parentId` does, and that
card may live on any board. Creating one requires read access to that board -
but the person who created it is not the person the publication is sending to.
Every subscriber of the board carrying the linked card was getting the source
card's full document and all of its children, whether or not they may see the
board it lives on.
The same answer as the ancestor cursor: a linked card whose source board the
subscriber cannot see is not sent. Cards on THIS board are their own answer,
since they are already going to this subscriber. The five cursors repeated the
same fifteen-line preamble verbatim; they share one helper now, memoized per
board, which is also what stops the sixth from being written without the check.
Two more findings from the same audit, in the same file and its neighbour:
- Three further hand-written copies of the board-visibility `$or` (the boards
publication, getAllBoardsPage, getAllBoardsTileData). All three were correct,
which is exactly the state RevokeBleed's copy was in before it drifted. They
call boardVisibilitySelectors() now, so there is no hand-written copy left in
the file.
- The STREAMING attachment export read `versions.original.path` with nothing but
an existsSync. The attachment allow rule refuses a client-supplied path, so
this is not the PathBleed hole - but it was the last place a stored path was
read without the containment check, and a path is only as trustworthy as every
way it could have been written.
tests/crossBoardParentCardLeak.test.cjs grows six checks: all five cursors go
through the helper, the helper asks the same visibility question, no cursor
builds its own linkedId list any more, the assigned-only member narrowing
survived the de-duplication, the helper is memoized, and no hand-written
visibility array remains.
Thanks to Alpastx and xet7 !
PathBleed, RevokeBleed, ParentBleed and CommentBleed, each linking its own fix
commit and its Hall of Fame page, under a CRITICAL SECURITY ISSUES header.
Thanks to Alpastx and xet7 !
Over DDP, deleting a comment is author-or-board-admin, and a board that sets
`restrictCommentEditing` takes even the admin's ability away (#5906). That rule
was enforced in a CardComments collection hook keyed off the Meteor userId, with
an early `if (!userId) return` for the genuine server-internal callers - board
copy, cleanup, migrations - that run with no authenticated user.
An HTTP request carries no Meteor userId into the invocation context. So
DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId
which checked only `Authentication.checkBoardAccess` - board MEMBERSHIP - reached
the collection with no user, the hook took its "server-internal, trust it" path,
and any normal member could delete any comment on the board. HTTP 200, the
comment gone, restrictCommentEditing or not, while the same deletion over DDP
was correctly refused (CWE-639, CWE-863). Broken object-level authorization:
harassment and evidence destruction on a shared board.
The fix does not rely on the hook seeing something it cannot see. The handler
loads the comment - 404 when there is none - and applies the same rule itself,
with req.userId, before removing anything. That rule is now an exported function
(`assertCanMutateComment` in models/cardComments.js) that the hooks and the REST
handler both call, so DDP and HTTP cannot enforce different things; it carries a
403 statusCode so a refusal answers Forbidden rather than 500. The no-userId
path stays, documented for the internal callers it was written for.
tests/restCommentDeleteAcl.test.cjs pins that the handler loads the comment,
that the ACL runs BEFORE the removal, that membership alone is no longer
sufficient, that the DDP hooks still call the same function, and - so a future
edit route cannot repeat this - that no REST PUT for comments exists without the
same check.
Thanks to Alpastx and xet7 !
Fixes GHSA-pqr4-rxgp-hv2m.
A card's `parentId` may name a card on ANOTHER board, and setting it was
authorized only against the CHILD board's write ACL - nothing asked whether the
person setting it, or the people subscribed to the child board, were allowed to
see the other board at all.
The board publication walks the whole ancestor chain, because the
'prefix-with-full-path' subtask setting renders a subtask's complete path
(#3453), and it published the complete ancestor card DOCUMENTS to every
subscriber of the child board. So a member who could write on shared board B and
knew a card id on private board A could point a card at it and have A's card -
title, description, custom fields - delivered over DDP to people who are not
members of A (CWE-200, CWE-862).
Closed at both ends, because either alone leaves half the hole open.
The WRITE refuses a parent whose board the actor cannot see. That is one
question, asked in one place (server/lib/visibleBoardIds.js) with the same
selectors the publication and All Boards use - so an ACTIVE org, team or domain
share counts and a revoked one does not. It is enforced on the REST card create,
on the REST card update (the PUT the report used), and on DDP by a deny rule
covering both insert and update, beside the cross-board MOVE deny that
GHSA-gm7v-pc38-53jr added. A parent that does not exist is refused too. Clearing
or omitting a parent is untouched.
The PUBLICATION sends only the ancestors whose board the subscriber may see. The
board being published is its own answer, so a normal same-board subtask path is
unchanged; an ancestor on a board the subscriber cannot see is simply not sent,
and a chain that yields nothing publishes nothing.
This is the shape of the check the linked-card path already made - creating a
linked card requires read access to the source card's board - applied to the
field that did not have it.
tests/crossBoardParentCardLeak.test.cjs pins the filtered cursor, that the
unfiltered publish of every ancestor is gone, that both REST paths validate
before writing, that the deny rule covers insert as well as update, and that
clearing a parent and the pre-existing cross-board move deny still work.
Thanks to Alpastx and xet7 !
Fixes GHSA-jvv9-498p-hxrg.
`isActive: false` is how a board admin REVOKES a share with an organization, a
team or an email domain. The All Boards list honoured it - it matches shares
with `$elemMatch: { ..., isActive: true }` - so a revoked user watched the board
disappear from their list and everything looked correct.
The `board` publication did not. That is the publication that sends the board
document and its lists, swimlanes, cards, comments and attachments, and it
matched shares with a dotted selector instead:
$or.push({ 'orgs.orgId': { $in: orgsIds } });
$or.push({ 'teams.teamId': { $in: teamsIds } });
$or.push({ 'domains.domain': { $in: emailDomains } });
A dotted path matches an element of the array without saying anything about
that element's other fields, so isActive was never consulted. Anyone who still
knew the boardId - a bookmark, a browser tab, a note - could subscribe and
receive the whole private board after their access had been taken away
(CWE-639, CWE-863). A revoke the primary data publication does not honour is
not a revoke.
The rule was written out twice and the two copies disagreed, so it is written
once now: models/lib/boardVisibilitySelectors.js builds the `$or`, both
Boards.userBoards and the `board` publication call it, and every share kind is
matched with `$elemMatch` requiring `isActive: true`. `includePublic: false`
still drops the public clause and only that clause, which is what the search
over all boards needs.
tests/boardShareRevokeBypass.test.cjs pins each share kind's selector shape,
that no clause anywhere matches a share without requiring isActive, that the
three dotted selectors are not produced at all, and that junk in the id lists is
dropped rather than handed to Mongo. tests/searchBoardScope.test.cjs asked its
question of the old array literal; it now asks it of the builder - the
behaviour it protects is unchanged.
Thanks to Alpastx and xet7 !
Fixes GHSA-gwc4-fw7p-gw58.
Attachments blocked client-supplied `versions.*.path`; avatars did not. The
avatar allow rule was the single line `update: isOwner` - being the owner let
you write ANY field of your own avatar document, `versions.original.path`
included. That field is server-managed metadata: it says where on disk the
bytes are.
Board export then read it. For every member whose avatar is a local WeKan file,
the exporter opens `versions.original.path` and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
/etc/passwd - or at anything else under WRITABLE_PATH - export a board they are
a member of, and decode the file out of the JSON. Arbitrary file read as the
WeKan OS user (CWE-22, CWE-73), from any account that can own an avatar.
Both halves are closed.
The WRITE: the guards that attachments had are now in one module,
models/lib/fileVersionFields.js, and BOTH permission files import it - avatars
had gone without them precisely because each collection wrote its own copy.
Avatars refuse an insert carrying versions.*.path or .storage, refuse any update
touching the versions subtree, and restrict updates to the same field whitelist
attachments use. Being the owner is still required; it is no longer sufficient.
The READ: nothing is read from a stored path unless the path RESOLVES to
somewhere inside WeKan's own storage (models/lib/storagePathContainment.js).
That is the half that also holds when a path is poisoned some other way - a
document written before this fix, a restored backup, a bad migration. The check
sits at getBase64Data, the one place the export turns a stored path into bytes
for attachments and avatars alike, and at both avatar call sites. The download
path in fileStoreStrategy.js has always checked containment this way; its
private copy of the function is gone and it uses the shared one, so the download
and the export ask the same question.
tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that renaming an avatar still works, that ..-escapes are
resolved rather than string-matched, that a sibling directory with the root as a
prefix is not inside it, and that no unguarded read of a stored version path
remains in the exporter.
Thanks to Alpastx and xet7 !
Fixes GHSA-4mxf-m8pq-xc9p.
An audit of releases/ against who actually calls each script. It holds 89 of
them, and they divide cleanly:
20 invoked by .github/workflows - CI checks out the repo and runs the FILE, so
these cannot move anywhere;
65 reachable from the build.sh / build.bat Releases menu, which is what the
menu is for;
1 sourced by another script (ensure-tools.sh);
3 referenced by nothing that runs.
Of those last three, wekan-entrypoint.sh is the Docker image's entrypoint - named
by the Dockerfile, not by a caller in this repo - so it stays.
old-build-bundle-arm64.sh and translations/old-pull-translations.sh are the other
two, and both were already recorded as superseded, by build-bundle-arm64.sh and
pull-translations.sh. Nothing referenced them but the exemption list that excused
them from being menu entries. They are deleted, and their exemptions with them.
Two more exemption notes were wrong rather than stale-adjacent:
fix-changelog-hashes.sh said "Setup -> Update git runs it", an option that no
longer exists - it is Setup -> git pull and git push now, both of which run it -
and resolve-fork-node-tag.sh excused a file that is not in the tree at all. An
exemption naming a missing script is an exemption nobody can check.
Checked after: every remaining releases/ script is either a menu entry or exempt
with a reason, and no exemption names a file that does not exist.
Thanks to xet7 !
"Update git" fetched, rebased, ran the CHANGELOG hash repair, printed git status
and left the rest to the reader - and the rest is where the mistakes were. A
rebase rewrites local commits, so CHANGELOG links written before it name commits
that no longer exist; the repair fixes the file but commits nothing, so the next
push carries a dirty tree; and a push rejected as non-fast-forward leaves a repo
that needs a pull the caller has to know to run. Three manual steps after an
option whose name did not say what it did.
Two options now, one per direction, each ending in a state that is either correct
or unchanged:
git pull - fetch; fast-forward when there is nothing local to replay (no
commit is rewritten, so no link can go stale); otherwise rebase,
then repoint the CHANGELOG links the rebase moved AND commit that,
so the tree is clean. A conflict is the one thing a script must not
paper over - a half-applied rebase IS the wrong state - so the
rebase is aborted, the repo is exactly as it was, and the message
says what to run by hand.
git push - repair the links BEFORE publishing them, since a stale link that
reaches GitHub 404s for every reader; say so when there is nothing
to push; and on the one rejection with an obvious answer, origin
moved, pull and retry ONCE. Never force: force-pushing a branch
somebody has pulled rewrites their history too, and a second
rejection is a different problem that retrying does not fix.
build.bat had a 41-line batch REIMPLEMENTATION of the old flow. It is gone: both
options there call `bash ./build.sh git-pull` / `git-push`, so there is one
implementation of this logic and Windows cannot drift from it. The same two are
CLI commands (./build.sh git-pull, git-push), so the menu, the .bat and the
command line are the same code.
Verified against throwaway repositories - an origin plus two clones - rather than
by reading: already-up-to-date does nothing; origin-only-moved fast-forwards and
rewrites no commit; both-sides-moved rebases, and a CHANGELOG link to a rewritten
commit is repointed to the replayed commit with the same subject and committed,
leaving the tree clean; a conflicting rebase aborts with HEAD, the working tree
and the index exactly as before and no rebase in progress; a rejected push pulls
and lands on the second try; and pushing with nothing to push says so.
Thanks to xet7 !
v10.72 was tagged and published at 08:32; the PatternBleed fix was committed at
09:05. Its entry went into the section directly above the previous release, and
by then that section had been renamed from "Upcoming" to "v10.72" by the release
commit - so the changelog claimed a released version contains a fix that is not
in its tag. Checked rather than assumed: the v10.72 tag points at "Bump versions
for v10.72", and the fix is not an ancestor of it.
There is a "# Upcoming WeKan ® release" section above v10.72 again, holding the
security subsection, and v10.72's first subsection reads "This release updates
the following dependencies:" once more instead of the "and updates" it was left
with. The Upcoming section carries the shape CLAUDE.md prescribes: the
**In short:** paragraph, then the binaries table - v10.72's rows, since nothing
here rebuilds them, and the paragraph says so - then the subsection, then the
closing thanks line.
The entry's commit link is repointed. `git pull` rebased the three commits after
the release onto it, which gave them new hashes, so the link written before the
rebase named a commit that no longer exists.
And a real bug in the guard that was supposed to notice all this:
/^(This release|and ) .*:$/
The space is INSIDE the alternative AND after it, so matching an "and" header
needs "and updates" with two spaces. Every `and ...:` subsection header
therefore failed to match, and the grouping check never reset its current group
at a subsection boundary: entries under a later subsection were attributed to the
last group line above them, which is exactly the mistake that loop exists to
catch. With it fixed, the section reports 22 grouped entries and one loose - the
one-entry security subsection, which CLAUDE.md keeps flat.
Thanks to GitHub CodeQL code scanning and xet7 !
CLAUDE.md's security process is the Hall of Fame as well as the CHANGELOG: the
name, the row in hall-of-fame/index.html and the page under it. I had written the
entry without them.
Thanks to GitHub CodeQL code scanning and xet7 !
Its own SECURITY ISSUES subsection, first in the release as CLAUDE.md prescribes,
so the dependency subsection that used to open it becomes an "and updates".
Thanks to GitHub CodeQL code scanning and xet7 !
Code scanning alert #431, tests/releaseNodeSources.test.cjs:
new RegExp(`^\s*${p.replace('-', '-')}\)\s+nodename=`, 'm')
It reads as "escape the hyphen before interpolating this into a regex" and does
nothing whatsoever, so the value went in raw. Nothing failed, because a hyphen
outside a character class needs no escaping - but the guard it looked like was
not there, and a platform name carrying a `.` or a `+` would have matched the
wrong row or thrown. CWE-116, and CodeQL is right to call it out: the usual cause
of the shape is a mistyped backslash escape, where '\"' is silently just '"'.
The platform name is escaped for real now, with the same escapeRegExp the other
guards in tests/ use.
tests/noIdentityReplacement.test.cjs catches the class rather than the instance,
because code scanning reports these days later in a web UI and the node suites
report in fifteen seconds. Three things it took to make it honest:
* it compares the two sides as VALUES, not as source text. '\"' IS '"' - that
is the entire point of CodeQL's example - so a text comparison would call
them different and miss the mistake the guard exists for.
* the two quote styles are separate alternatives, not a backreference with a
character class excluding both quotes: `replace('"', '\"')` contains a double
quote inside a single-quoted literal, so the first shape of the pattern could
not match the very example above. Caught by asserting the pattern against
that line rather than trusting it.
* comments are stripped and the guard skips its own file: this one and the file
it was written for both QUOTE the bad line to explain it, and a guard that
reports the sentence describing a bug as the bug gets switched off.
Verified in both directions: the repository is clean now, the same scan against
the previous commit reports the offending line, all three bad shapes are detected
and four working replacements - including a real escapeRegExp - are not.
Thanks to xet7 !
The two commits that fixed what the companion-repo move and the cross-board card
fix left failing, and the Playwright retry loop that could not reach its own
error. The run after them was green: 328 node suites, 518 mocha tests,
249/243/243 in the three browsers, 98 conformance cases with none differing, and
FerretDB's own suites.
Thanks to xet7 !
Down from five failures to none. Both of these are the same kind of thing - a
check that described the world before a change, and a loop that could not reach
its own error.
swcLegacyHelpers still required the .gitignore comment block that named each
clone: `- node/ : clone of the Node.js ...`. That comment went with the entries
it described. What has to be explained now is the one directory that replaced
them, so .gitignore says what .tools/ holds - companion repos, cloned inside the
checkout so they work in a Flatpak sandbox that shares only this directory, with
the entries it replaced named so the change is legible - and the guard requires
THAT. An ignore of a whole directory with no explanation is the kind nobody dares
remove later. The rule about the shape of a per-repo entry stays: if one is ever
re-added it must be rooted and a directory, since a bare `node` matches anything
a package happens to call that.
The webkit failure was one test of 243, in the shared boardPage fixture rather
than in an assertion:
Test timeout of 60000ms exceeded while setting up "boardPage".
Error: page.waitForTimeout: Target page, context or browser has been closed
openBoard retries five times, waiting up to 20s for a list each time with a
second between - about 105 seconds, against a 60 second test timeout. So the loop
could never reach its own error: Playwright killed the test first, and the report
said the page had closed instead of saying the board never rendered. Retrying
past the point where the result can still be used is not resilience, it is a
worse error message.
The loop is bounded by a deadline now, 45s, leaving room for the rest of the
fixture inside the 60s timeout. The first attempt keeps the full 20s look, later
attempts get whatever is left, and it stops rather than starting a wait it cannot
finish - so a slow board still gets one long look, and a board that will not
render fails with "did not render any lists within 45s". Simulated across three
cases: never renders (gives up at 45s, 3 attempts), renders at 4s and at 19s
(both on the first attempt) - all inside the test timeout, where the old shape
overran it by 45 seconds.
Thanks to xet7 !
An EVERYTHING run found four node suites and one mocha test failing. All five
are consequences of the two changes before them, and each one is a guard doing
its job.
Three broke on the companion-repo move:
* meteorignoreScanScope listed the foreign checkouts by name - FerretDB, node,
mongo-tools, TSC, gitea, the two WeKan variants - one .meteorignore entry
each. They are in .tools/ now, one entry that covers all of them, so a list
of names is a list of history: it fails for the ones that moved and says
nothing about the next repo somebody clones. It asks the property instead:
.tools is excluded, and nothing at the top of this checkout that is its own
git repository is left for Meteor to walk. The variant check became the
stronger version of itself - no directory with client/, server/ and models/
is reachable from the top - since a second WeKan is loaded EAGERLY, not
merely scanned.
* swcLegacyHelpers checked .gitignore for the same list; one entry now.
* cpuMonitorWiring reads FerretDB's Go source and still opened FerretDB/. It
reads .tools/FerretDB, and skips with a note when the clone is not there -
it is a separate repository, and not every checkout has cloned it.
dockerComposeBackends caught a real omission: the LDAP_GROUP_BASEDN block from
#5539 went into docker-compose.yml only, and every compose file's wekan service
must be identical - what a user reads when they edit their settings must not
depend on which backend they picked. The block is in all of them now.
The mocha failure is the one worth reading twice.
cards.dependencies.move.tests.js pinned move() issuing the inbound-dependency
cleanup itself: a multi-document update with a compound selector. That is
exactly what #6572 removed, because this helper runs in the client bundle where
Meteor allows updates only by id - it threw "Untrusted code may only updateAsync
documents by ID" on every cross-board move, before the move ran. So the test was
pinning the bug. It now asserts the contract that replaced it: move() clears the
moved card's own dependencies, and makes NO update that is not by id - not a
selector, not multi. `{ _id: x }` still counts as by id, since Meteor's rule is
"by id", not "not an object". The inbound half is the server hook, pinned by
tests/cardMoveUntrustedUpdate.test.cjs.
Verified against the tree: all four node suites' conditions hold, the four
compose files the guard compares are identical again, and the FerretDB source
the CPU guard reads is present at the new path with every pattern it asserts.
Thanks to xet7 !
An audit of the TODO Later backlog against the open issues and the current
source. Three no longer belong there.
#3113 - an outgoing webhook said who did something by DISPLAY name and had no
field for the login name. params.user comes from getActivityUserName(), which
prefers getName() because the same params feed the e-mail notification text,
where "Lauri Ojansivu commented on ..." is what a person wants to read; a webhook
consumer needs the identifier, received "Lauri Ojansivu" where it needed "xet7",
and matching users by display name is wrong the moment two people share one.
Changing what `user` means would break every consumer already reading it, which
is what left this recorded as needing a maintainer decision. It needs none: the
username travels as its OWN field. `user` is untouched, `username` is beside it
in the default WEBHOOKS_ATTRIBUTES list, and a deployment that pinned its own
list still gets exactly the fields it asked for.
#6542 - "Import finished. Users imported: 60, but only 25 are listed". The Admin
Panel People pane paginates and passes its total to the shared table page
(totalLabelKey: 'people-number'), so the 60 the importer reported is on screen
next to the 25 rows of the current page. That is what the report was about, and
it is already true in this source.
#6500 - Docker `latest` not loading in a browser: already closed upstream, so it
was only sitting in the backlog.
The rest of the backlog stays, each with its reason: they need an SMTP server, an
LDAP directory, a container, a browser to drag in, or a maintainer's decision on
an intended contract - none of which a source reading can settle.
tests/webhookUsername.test.cjs pins both halves of the webhook change: that the
username is there and set from the same user document as the display name, and
that `user` still means what it always did, including the notification text that
depends on it. Three of its seven assertions fail on the previous commit.
Thanks to xet7 !
Fixes#3113,
Fixes#6542.
wekan/FerretDB is a separate git repository that WeKan's test runs need: the
conformance run builds it from source, and "Run all FerretDB tests" runs its own
suites. It was cloned as a subdirectory of the repo root, which is why
.gitignore and .meteorignore carried an entry per companion repo - nine of them,
each one a chance for a clone to reach a commit or a Meteor rebuild. They live
in .tools/ now, one directory both files already exclude.
build.sh gains ensure_tool_repo: it returns the path to .tools/<name>, cloning
wekan/<name> if it is not there - SSH first, since a maintainer pushes, HTTPS
after, so everyone else still gets a working clone. Its messages go to stderr,
because the path is what it prints. The directory comes from the script's own
location rather than the caller's cwd, so a clone cannot land in whatever
directory somebody happened to be in.
EVERYTHING's FerretDB stage calls it rather than assuming stage 3 already
cloned - a whole-run must not depend on the order of its own stages -
db-conformance.sh builds from .tools/FerretDB and makes .tools before cloning,
and build.bat does the same on Windows: it clones into .tools\FerretDB with the
same SSH-then-HTTPS fallback instead of printing instructions and stopping, and
says so plainly when git itself is missing.
CLAUDE.md's directory list describes the new layout, including that a companion
repo is cloned on demand, so a fresh checkout needs no manual setup.
tests/toolsDirectoryRepos.test.cjs pins both halves: that nothing still reaches
for the old path and both scripts clone, and that a repo INSIDE .tools still
finds its way back out - FerretDB's own build.sh used to reach WeKan's log
directory by counting ../, which lands one level short from .tools; it searches
for the checkout now (fixed in the FerretDB repo, same commit message).
Thanks to xet7 !
#6430's remaining flicker and #5707's invitation email both need something this
environment does not have - a browser to drag in, and a real SMTP server - so
they are recorded with the reason and what was already established, rather than
guessed at.
Thanks to xet7 !
#5251 asked for the last change date of a list, to build an offline Android
client that syncs only what moved. Neither half existed: GET
/api/boards/:boardId/lists returned {_id, title} and no dates at all, and while
the single-list endpoint returns the whole document - modifiedAt included - that
date answers the wrong question. It moves when the LIST document changes (title,
sort, archived); a card being added, edited or archived does not touch it, and
those are the changes the client is asking about.
The list endpoint now reports both:
modifiedAt - the list document itself
cardsModifiedAt - the newest change among the cards in it, null when none
One query for the board's cards and a reduction in memory, not a query per list -
a query per list is what made this expensive enough to ask about. Archived cards
are included on purpose: archiving is one of the changes named in the issue.
The reduction is models/lib/listActivityDates.js, a pure helper, so it is
testable without Meteor and so the handler stays readable. It reads
dateLastActivity as well as modifiedAt and takes whichever is newer, because
boards written by older WeKan carry only the former; a card with no usable date
is skipped rather than counted as now or as zero; and a list whose cards carry no
date is ABSENT from the result, so the endpoint reports null rather than an
invented time - a client polling on a wrong date either never syncs or syncs
forever.
tests/listActivityDates.test.cjs exercises the helper directly: newest wins, per
list, legacy field, string and numeric dates, an undated card cannot drag the
answer backwards, and a negative case feeding it nulls, strings, numbers, a card
with no listId and an Invalid Date - none of which may throw, since the handler
passes whatever the database holds. It also pins the endpoint's shape: both
fields, one cards query, only the three fields it reduces over, and no
`archived: false` on that query.
Thanks to mimZD and xet7 !
Fixes#5251,
#5539: WeKan could not authenticate LDAP users whose groups sit in a different
ou than the users themselves.
Both group searches - getUserGroups, which feeds the login restriction, admin
status sync, group->role sync and org/team sync, and isUserInGroup - searched
this.options.BaseDN. That is the USER base. A directory that keeps ou=groups
beside ou=people, which is a common layout and the reporter's, has no groups
under it, so every group search came back empty; with LDAP_GROUP_FILTER_ENABLE
on, isUserInGroup concluded "not a member" and refused the login. Nothing in the
package could say where the groups were - there was no setting for it.
LDAP_GROUP_BASEDN says where. Unset, or set to a blank string, it falls back to
BaseDN, so a directory that keeps users and groups in one subtree behaves exactly
as it did; a present-but-empty env var is one somebody meant to fill in, not a
search base, and searching "" would silently search the directory root.
The three USER searches - searchUsers, getUserById, getUserByUsername - keep
BaseDN. Pointing those at a group subtree would break login for everyone, so the
test pins which searches moved and which did not, in both directions.
Documented beside the settings it belongs with, in docs/Login/LDAP.md and
docker-compose.yml: a setting nobody can find is one that does not exist.
tests/ldapGroupBaseDn.test.cjs pins all of it, including the fallback and the
documentation. Six of its nine assertions fail on the previous commit; the three
that pass there are the user searches, which is the point of asserting them.
Not verified against a directory - this sandbox has none. The defect is read from
the source: two group searches, one user base, no way to say otherwise.
Thanks to leandro-cyberproject and xet7 !
Fixes#5539,
The screenshots in #6465 (comment 5187985815, images only, each with the fault
circled):
"Checkbox is misplaced" - Admin Panel people table. The select-all box and the
"all users" it labels were two loose inline nodes in a centred table heading: the
box is replaced content with its own baseline, and at the width that column
happens to have, it landed on the word. They are one <label for> now, an
inline-flex row with a gap that cannot collapse - which also makes the word click
the box.
"This checkbox is crazy" - Member settings. The rescue-card-description row
carried a `b ` spacer and `.left`, which is float: inline-start. A float
inside a flex container is ignored and the spacer became a stray flex item, so
the box drifted up beside the "Card settings" heading instead of sitting with its
own text. It is now the same shape every other checkbox row in that file uses:
.flex anchor, .materialCheckBox, span.
"(2/5) <-- move this up here" and "Make this same height as the lanes left and
right" - one cause, not two. The WIP counter is already inside the h2 right after
the title; the title is a `+viewer`, and .viewer is display: block with a 22px
min-height, so it pushed everything after it onto a second line AND reserved a
band under it, which is what made that list's header taller than its neighbours'.
Inline in the heading, the counter stays where the markup already puts it. Same
treatment the quick-access header and the table view already give their viewers.
"Please move the button down. This is too close ... panic ;-)" - the backup
schedule's Save sat directly under the 1..28 day buttons, close enough to hit
while aiming for a date. The gap goes on the form-group that FOLLOWS the day
grid, so it applies where that grid is and nowhere else.
"The alignment is out of place" - the attachment move button. This one is
normalisation rather than a measured fix: the row is align-items: flex-end, so a
margin of the button's own offsets it from the controls it acts on; that margin
is zeroed and the button aligns with the controls explicitly. Whether that is the
whole of what was circled needs a browser, and the test says so.
tests/uiSpacingReports.test.cjs pins each cause, including the premise behind the
WIP fix (.viewer really is a 22px block). Six of its nine assertions fail on the
previous commit; the three that pass there are the premises, which is the point
of stating them separately.
Not verified in a browser: this sandbox has none. Each fix is read off the markup
and CSS against what the screenshots show.
Thanks to Alishara and xet7 !
#5174: an OIDC login against a provider that rejected the scopes failed with
"Error in OAuth Server: Cannot read property 'ocs' of null", which says nothing
about the refusal that caused it.
getTokenContent() returns NULL for a token it cannot parse, and the ADFS/B2C
branch assigns that straight into userinfo. The next line was the Nextcloud hack,
`if (userinfo.ocs)`, so the first thing to touch the failed response was a
property read on null. Every claim read after it had the same problem - that line
was only first by accident of ordering, which is why the fix is not one null
check but an order: the provider's answer is validated once, as a whole, before
anything reads a field off it.
Checked, in this order, each naming what failed:
* the token response is an object at all;
* it carries an access_token or an id_token - a 200 with neither is the
provider answering with a complaint, so the error lists the fields that DID
come back (keys only: the values are secrets, and JSON.stringify of a token
response in a log is how refresh tokens leak);
* userinfo is a non-null object, saying WHICH path produced nothing (claims
expected in the access token, or the userinfo endpoint) and pointing at
OAUTH2_REQUEST_PERMISSIONS, which is what was actually wrong in #5174;
* the ocs and metadata hacks unwrap to something - `ocs` present with no
`ocs.data` used to set userinfo to undefined and fail on the next line;
* B2C's userinfo["emails"][0] is an array before it is indexed: a tenant that
does not release the claim made that undefined[0];
* expires_in parses to a finite number - parseInt(undefined) is NaN, and NaN
propagated into the account's expiry silently. Falls back to the OAuth2
default hour.
tests/oidcNullUserinfo.test.cjs pins the order rather than the individual checks:
it asserts each guard sits BEFORE the read it protects, since an edit that reads
a claim earlier would restore the original bug without touching a check. It also
pins that the no-token error names keys and never stringifies the response. All
eight assertions fail on the previous commit.
Not reproduced against a real provider - this sandbox has none. The null path is
read from the source: getTokenContent returns null for a falsy token, and the
ADFS/B2C branch passes its result to the line that threw.
Thanks to Esther125 and xet7 !
Fixes#5174,
Reported by email with a screenshot: the "Add card" link and the "Add" button
are too big, and things should be sized in relation to each other the way an h1
is bigger than an h2.
The measurements agreed. A list heading was 14px, while a minicard title and the
"Add card" link both set no size at all and inherited the document's 16px, and
the composer's Add button was a 50px-tall slab. So the column was labelled in
fine print, the affordance for making a card was as loud as the cards
themselves, and the biggest thing on screen while typing was a button.
The scale is now heading 16 > card title 14 >= add-card link 13, each stating
its own size instead of inheriting, and the Add button is a 34px control rather
than a slab. Mobile keeps its own larger sizes - the .mobile-view rules with
!important, and the @media blocks - because a phone is read at arm's length.
tests/listTypeScale.test.cjs pins the ORDER rather than the numbers, since that
is what "in relation to each other" means, plus that each of the three states a
size of its own: an inherited size is exactly how the link and the card title
both landed on 16px, level with each other and above their heading. It reads the
base scale only, skipping @media blocks, and takes the LAST rule for a selector -
.list-header .list-header-name has two rules, and reading the first one finds the
one that sets no size at all. Verified against the previous commit: heading 14,
card title and add-card link unset, button 50px - it fails on all four counts.
Not verified in a browser: this sandbox has none. The sizes are read from the
source, and the relative order is what the guard holds.
Thanks to xet7 !
#6572: every cross-board move failed immediately with "Not permitted. Untrusted
code may only updateAsync documents by ID. [403]", whether or not the card had
any dependencies, and before the move itself ran - so the card stayed put.
models/cards.js is isomorphic, so its helpers run in the client bundle, and
client code calls card.move() directly (list.js, swimlanes.js, minicard.js,
globalSearch.js, sidebarFilters.js). Meteor lets untrusted code update only BY ID
- a bare id or an { _id: ... } selector. The cross-board branch of move() cleaned
up inbound "Red Strings" (#3392) with a compound selector and multi: true, which
is rejected on the client every time, including when it would have matched
nothing. addDependency, setDependencyProps and removeDependency in the same file
each carry a comment saying updates must be by _id only; this was the one place
that did not follow it.
The card's own dependencies are still cleared by the move (that rides along in
the by-id update). The inbound half - the links pointing AT the card from the
board it left - is now a Cards.after.update hook in server/models/cards.js, where
a selector is allowed, and being server-side it also covers the REST API and
import paths that never called the helper at all. It pulls both stored shapes:
{ cardId, ... } objects, and the bare id strings older data still holds, which
normalizeDependencies hides on read.
The issue also reports that the REST workaround corrupts the card, and it does.
A PUT of boardId/listId/swimlaneId naming the DESTINATION board is not a board
move - isBoardMove needs newBoardId, newSwimlaneId and newListId - so that branch
never ran, while the same-board swimlane and list branches did: the card kept its
old boardId and got the other board's listId and swimlaneId written onto it,
belonging to a list and a swimlane on a board it was not on, showing on neither,
undone only by a hand-written database update. Those two branches now verify the
target belongs to the board in the URL and otherwise refuse with 400 naming the
parameters to use, and they are skipped during a board move - they would rewrite
listId before the isBoardMove update, whose selector pins the card's original
listId, so that update would match nothing and silently do nothing: the same
broken card by another route.
tests/cardMoveUntrustedUpdate.test.cjs pins both halves: every Cards update in
the isomorphic model is by _id and none is multi-document (it reads the whole
call, because multi is the third argument and a check that stopped at the first
one could never fail), the inbound cleanup is server-side and keyed on a real
board change, and the REST branches are board-checked and skipped during a move.
Verified against the previous commit: the model guard reports the compound
selector and the multi update, and 6 of the 8 REST assertions fail.
Thanks to jullbo and xet7 !
Fixes#6572,
The EVERYTHING run's WeKan stage failed on one node suite:
dbConformanceWiring, "build.bat: playwright-all must be logged". It is a real
gap, and splitting the whole-suite browser job into three uncovered it rather
than caused it.
`call :onelog playwright-all` existed in exactly one place: inside the combined
three-project browser line of the sequential all-tests flow, which is gone now
that each browser is its own job with its own log. Where it did exist it never
worked - the call sat inside the `cmd /c` string of a STARTED child process,
which has no build.bat labels to call, so %ONELOG% was empty and Tee-Object was
handed an empty path.
Meanwhile the option the name was supposed to cover, Tests -> 11 "Playwright ALL
browsers", wrote no log at all: one `playwright test --project=chromium
--project=firefox --project=webkit` call straight to the terminal, nothing left
to read afterwards. That is the exact thing the guard is about, and build.sh's
same option has always written one log per browser.
So the option now runs the three browsers one at a time - unchanged, and still
sequential because three at once against one dev server exhausts RAM on smaller
machines - each through the same :onelog helper as every other Tests option, and
each with its own --output so Playwright does not clear another browser's traces
at startup. The guard drops `playwright-all`, which named an implementation that
is gone and was broken, and gains what it was reaching for: that the ALL-browsers
option logs, and that it covers all three.
Verified: every build.bat label reference resolves, all seven menus still print
exactly what they dispatch, the build.sh menu entries all still match a case arm,
and the whole-suite per-browser jobs are untouched. Mocha ran in this run for the
first time since it was fixed - 518 passing - and the other stages were green:
320 node suites with this one failure, 249/243/243 in the three browsers, the
conformance run and all of FerretDB's own tests.
Thanks to xet7 !
Renaming the menu entry broke it. choose() shows the SHORT labels and sets $opt
to the FULL DESCRIPTION of the one picked, and the dispatcher hundreds of lines
below matches that description as a case arm - so the description is an
identifier written twice, and the rename in the previous commit changed only one
of them. With no arm matching and no catch-all, the case fell through, the
`for _once` loop ended and the script EXITED: choosing Tests -> 1 printed nothing
and ran nothing.
The arm now carries the same string as the menu entry, so EVERYTHING runs its
four stages again. Two guards so it cannot come back:
* tests/buildScriptParity.test.cjs checks every one of the 25 choose() entries
against the case arms, and fails on a description that matches none. It fails
on the previous commit's build.sh.
* the dispatcher has a *) arm that says which option has no handler and that
nothing was run, so if one ever does get past the test, the person in front
of the menu is told rather than dropped back to the shell.
build.bat was checked for the same thing and cannot fail this way: its menus
dispatch on the NUMBER typed, not on a sentence, and its EVERYTHING hands the run
to releases/run-everything.sh rather than reimplementing it. Verified anyway,
both directions: every printed menu number has an if-dispatch in all seven menus,
and every goto/call target resolves to a label. That is now pinned too.
Thanks to xet7 !
Also repoints six entries at the commits that exist on this branch. The board
export and mocha entries were written against a commit that was amended after
they were filled in, and the four snap entries against one that was amended the
same way, so each of them linked a commit the amend had orphaned. A CHANGELOG
entry cannot carry the hash of the commit it is in; it goes in its own commit
afterwards, linking the code commit.
Thanks to xet7 !
#6573: picking up a card made every list on the board expand horizontally to fill
the window, and dropping it snapped them back, which makes landing a card in the
intended list a guess. Two correct changes met. A list's custom width is an
inline --list-width custom property, and what turns it into a width is a rule in
list.css whose selector needs an ancestor: .js-swimlane, .dragscroll, or a
swimlane-<id> id. In lists view the container is
.swimlane.list-group.js-lists.dragscroll - no js-swimlane class, no such id - so
.dragscroll was the only one of the three that matched, and every list's width
there hung on that one class. #6558 then taught a card drag to stop the board
panning under the same pointer, and it does that by REMOVING the dragscroll class
for the duration of the drag. So the drag deleted the class the width rule
matched on: flex: none and the width declarations switched off and the lists
re-flowed, for exactly as long as the drag lasted. It also explains the
workaround in the report - dragging one list's edge first sets that list's width
through the resize path, so that column stopped jumping while the others did not.
A layout rule may not hang on a class that an interaction removes. .js-lists is
on the container in both views and nothing takes it off, so the width rules,
their mobile-mode counterpart and the resize rules name it now.
tests/listWidthDuringDrag.test.cjs pins that every width rule still matches in
lists view with dragscroll gone - it fails on the previous CSS - and pins the
premise that suspending the pan really does remove that class.
build.sh -> Tests -> EVERYTHING is what a release is tested with, so a check that
is in the Tests menu and in no whole-run is a check that runs when somebody
remembers it. The floating-promises guard was that: menu entry 12, in no run. It
is the first of four stages now - seconds, so an unawaited permission check is
reported before an hour of browsers - and its return code is in the verdict like
the others. What runs there is checks only: the menu entry may install ripgrep
and the @typescript-eslint packages and write the rule into .eslintrc.json, which
is a person setting the guard up, while EVERYTHING runs unattended and must
modify nothing - a run that rewrites .eslintrc.json is no longer testing the
commit it started from. The two checks are one shared function using grep rather
than ripgrep, so they also work where nothing may be installed.
build.bat ran the three browsers as one Playwright invocation into one
wekan-alltests-browsers.log, where neither "which browser failed" nor "what did
WebKit print" was answerable afterwards, and CLAUDE.md's "check the newest test
logs" names the per-browser files. It starts chromium, firefox and webkit as
three jobs with a log, a status and a summary row each now, with a per-browser
--output because Playwright clears its output directory at startup. EVERYTHING is
not reimplemented there: Windows hands the run to releases/run-everything.sh,
which calls build.sh --run-everything, so the new stage arrives on both platforms
at once. The parity guard pins all of it, including that the shared checks
install nothing.
Verified without node or meteor, which this sandbox has neither of: the extracted
checks were run for real against the tree (clean) and against fixtures with an
unawaited auth call, a missing rule and no .eslintrc.json at all (all three fail
as they should); the new and changed guards were ported assertion-for-assertion
to Python and run against the same files, including that the #6573 guard fails on
the pre-fix CSS; build.sh passes bash -n and every build.bat call now resolves to
a label.
Thanks to yulqen and xet7 !
Fixes#6573,
The newest test run had two failures.
tests/changelogFormat.test.cjs failed on "the binaries table follows the
summary": the Upcoming section written after v10.71 had lost the binaries table
that every release section carries. It is restored from what v10.71 shipped, and
two group lines that had wrapped onto a second line are one line again, which is
what the grouping guard requires.
The mocha stage did not run at all. client/lib/tests/boardTriggersClass.tests.js
reads boardTriggers.jade and boardTriggers.js off disk with fs and path, which
worked only while node-polyfill-webpack-plugin's browser shims were in the client
bundle; the @meteorjs/rspack 2.1.0 update dropped that plugin and the client
build stopped with "Cannot find module 'path' for matched aliased key 'path'".
That is not one suite failing - the bundle does not build, so every mocha suite
is skipped and the stage reports a crash where its results should be. The suite
was on the wrong side rather than wrong: it now lives in server/lib/tests, and
tests/clientTestsNoNodeBuiltins.test.cjs is the mirror of the existing guard that
keeps browser-only packages out of the server bundle.
Issue #6274, "export includes only comments from current year", and its fix in
PR #6275 were then checked against the current source. The fix is intact and
survived the streaming-export rewrite that came after it: both exporters select
comments by cardId and activities by boardId OR cardId. The follow-up its
reporter left on that PR - "the export is now missing the lists part" - is not
reproducible: result.lists has been written by the JSON exporter continuously
since the CSV/TSV export was added, both JSON writers emit the same fourteen
sections, and the flat formats carry each list's name on the card's own row. So
no export code changed; what was missing was the guard, and
tests/exportBoardSections.test.cjs pins the section list, that the two JSON
writers agree on it, that no exporter selects comments by board again, and the
two smaller fixes from that PR.
Not run here: this sandbox has no node, meteor or any other JS runtime, so the
new and changed guards were verified by porting them assertion-for-assertion to
Python and running that against the same files - changelogFormat 16/16, the
export and client-bundle guards clean. The mocha suites behind the fixed build
have not executed yet; the next run is what shows them.
Thanks to Tuphal, KhaoulaMaleh and xet7 !
Remove the 'commit only, do not push' guidance from both the maintainer-mode
intro and General practices; the maintainer decides when to push, directly on
the branch (no PR, no branches).
When a Launchpad remote build ends as 'Stopped' with no .snap, the job scans
the build log to tell a real credential failure from a build cancelled mid-run
(an OOM in the Meteor npm install, a build-farm reset). The scan pattern was
'unauthoriz|401|403', and a bare 401/403 matches any three digits in a large
build log - a package size, a hash, an 'attempt 4/6' line - so it fired on
almost every Stopped build and told the maintainer to re-create credentials
that were working: the build had reached 'Building:', which needs valid
credentials. The pattern now matches those codes only in an HTTP-error context
(HTTP Error 401, 403 Forbidden) or an explicit phrase (invalid credentials,
not logged in, bad credentials), so the credential hint appears only for a real
authorization failure.
releases/release-all.sh and releases/version.sh read a command's output
into an array with `mapfile`. `mapfile` (a.k.a. `readarray`) is a bash 4+
builtin, and macOS still ships bash 3.2, so `./releases/release-all.sh`
failed on a Mac with `mapfile: command not found` and could not trigger a
release from there. `brew install mapfile` finds nothing because mapfile
is a shell builtin, not a program.
Each `mapfile -t VAR < <(cmd)` is replaced with the portable
`VAR=(); while IFS= read -r line; do VAR+=("$line"); done < <(cmd)`, which
builds the same array on bash 3.2 and on the bash 5 the Ubuntu release
runner uses. Converted: release-all.sh's RELEASED version list, and
version.sh's three reads - the Node.js files, the MongoDB files, and the
two newest CHANGELOG release lines.
Thanks to xet7 !
Every place that downloaded bsondump, mongodump, mongoexport, mongofiles,
mongoimport, mongorestore, mongostat and mongotop from wekan/mongo-tools now
downloads them from wekan/mongo-tools-patches: the amd64 base bundle, the
per-arch overwrite in the Linux bundles and in repack-bundle-for-arch.sh, the
Windows and macOS bundle jobs, check-arch-binaries.sh, require-binaries.sh,
test-download-urls.sh, the Dockerfile, both snapcraft files, the Flatpak and
AppImage workflows, the Sandstorm build-deps note and the CPU-platforms docs.
wekan/mongo-tools was a fork of a large Go project that changed none of its
source - its six commits were all the build workflow. wekan/mongo-tools-patches
keeps that build and drops the fork: it clones the newest upstream
mongodb/mongo-tools release, applies the patches in dist/ (there are none yet,
which is the honest state of it), and publishes the same <tool>-<arch>[.exe]
assets with a .sha256sum beside each - the same model wekan/node-patches
replaced the wekan/node fork with.
Nothing else changes: the asset names, the per-tool tolerance for an
architecture that has no binary, and the checksum verification are what they
were, because the build script came across unchanged.
The new checkout is excluded from git and from Meteor's scan the same way
node-patches and the old mongo-tools clone are.
Thanks to xet7 !
WeKan's bundle .zip, Docker image and snap now resolve their Node.js from
three sources in order - https://nodejs.org/dist/ , then
https://unofficial-builds.nodejs.org/download/release/ , then the
wekan/node-patches releases - instead of only from the retired wekan/node
fork. A platform none of the three publishes a Node.js for is NOT BUILT that
release rather than failing it, and returns by itself on the first run after
one appears. Sandstorm is amd64 only and untouched.
releases/resolve-node-source.sh is the one place that order and the
platform-name mapping live; the bundle jobs, the extra-architecture preflight
and the Dockerfile all ask it, so the image and the .zip of one CPU cannot be
built on Node.js from different places. The snap takes its node out of the
bundle, so it follows without a change of its own.
Thanks to xet7 !
The win32 and mac-x64 preflights checked .../releases/download/${NODE_VERSION}/…
with NODE_VERSION=24 (the bare MAJOR), which 404s - the fork tags by full version
(v24.19.0) - so both jobs ALWAYS skipped. They now resolve the newest v24.x fork
tag carrying the asset via a shared helper, releases/resolve-fork-node-tag.sh
(authenticated with the workflow GITHUB_TOKEN, so the GitHub API is not
rate-limited); the win32 preflight hands the resolved node_full to the rebuild.
And the win32 native-module rebuild is completed. npm_config_arch=ia32 alone was
not enough: an ia32 addon must LINK against an ia32 node.lib, and nodejs.org
dropped 32-bit Windows in Node 23, so node-gyp cannot fetch win-x86/node.lib. The
rebuild now assembles a nodedir node-gyp CAN use - node headers (arch-independent,
from nodejs.org) plus the fork's own node-win32.lib (now published beside
node-win32.exe) at the two paths node-gyp looks in - and points node-gyp at it
with npm_config_nodedir, so bcrypt cross-builds to ia32 against the fork's Node.
(The inline python that resolved the tag broke YAML - a multi-line heredoc cannot
be indented into a `run: |` block - which is why it is a helper script, reused by
both preflights and mirroring embed-verified-node.sh / check-arch-binaries.sh.)
Thanks to xet7 !
Every extra arch is built by running the fork's target-CPU node UNDER QEMU to
rebuild the native modules, and qemu-user does not run every Node.js binary
perfectly. ppc64le crashed at node startup deserializing its V8 snapshot:
# Check failed: IsFreeSpaceOrFiller(filler).
... v8::Isolate::Initialize ...
qemu: uncaught target signal 5 (Trace/breakpoint trap) - core dumped
install-node-for-arch.sh: line 144: npm install ... Error 133
so the ppc64le leg failed. With ppc64le (and riscv64) still marked REQUIRED, one
such emulated-CPU failure failed the whole build-extra-arches matrix job.
They join s390x/i386/armhf/armv7/loong64 as best-effort (optional: true): a leg
that cannot run this release SKIPS with a warning instead of failing, so the
matrix never fails on one exotic CPU, the docker job (which now builds only the
bundles that landed) is never dragged down, and the release ships whatever built.
amd64 and arm64 (native, not in this matrix) remain the required core. ppc64le
returns the release it runs cleanly again - its qemu/snapshot crash is a
node-fork/emulation issue to chase separately, not something this bundle build can
fix inline.
Thanks to xet7 !
Three separate native-bundle failures in one release run:
* mac-arm64 failed the major->tag resolution. embed-verified-node.sh and
check-arch-binaries.sh call api.github.com to find the fork's newest v24.x tag,
but no step passed a token, so the call was UNauthenticated - 60 requests/hour
per IP, shared across runners - and 403-rate-limited intermittently. That is why
amd64 (which ran first) resolved fine while mac-arm64 (later) did not. A
workflow-level GITHUB_TOKEN: ${{ github.token }} authenticates every such call
(5000/hour), fixing the native jobs and the extra-arch preflight alike.
* win64 failed because the fork published node-win64.exe WITHOUT its
node-win64.exe.sha256sum, and the embed helper 404'd on the sidecar. The
checksum is a belt-and-suspenders check on top of an already-authenticated HTTPS
download from the fork, so a MISSING one is now a warning and the binary ships
UNVERIFIED (a checksum that is present and does NOT match is still fatal). This
matches how check-arch-binaries.sh already treats a missing fork checksum.
* win32 failed at setup-node: "Unable to find Node version '24' for platform win32
and architecture x86" - there is NO 32-bit Windows Node 24 (the reason the fork
exists). It now runs the runner's x64 Node to DRIVE node-gyp and cross-builds
the native modules (bcrypt) to ia32 via npm_config_arch=ia32; the shipped
node.exe is still the fork's 32-bit node-win32.exe. tests/releaseBundleCoverage
is updated for the ia32 cross-build.
Thanks to xet7 !
The docker job `needs: build-extra-arches`, a matrix. When any leg failed - a
single exotic CPU like ppc64le - the whole matrix job was "failed", and a job
whose `needs` dependency failed is SKIPPED. So one failed extra-arch skipped the
ENTIRE multi-arch image, even though amd64/arm64 and the other arches were fine.
Two changes make the image resilient:
* The job now runs on `always() && needs.release.result == 'success'`, so a
partially-failed build-extra-arches no longer skips it. It still lists
build-extra-arches in `needs`, so it waits for the successful legs to upload
their bundles first.
* A new "Decide which platforms" step probes which wekan-<version>-<arch>.zip
bundles actually landed on the release. amd64 and arm64 are required (no image
without them, and the release would have failed already); the exotic arches
(ppc64le, s390x, riscv64, 386, arm/v7) are included only if their bundle is
present, and a missing one is a ::warning that drops just that platform. The
decided set drives the wait loop, buildx --platform "${PLATFORMS}" and the
push-verify want="${WANT}" from one place, so they always agree.
The result: a failed or best-effort-skipped exotic arch drops only itself from
the image and returns the next release that builds it; the image is never skipped
and never fails because of one CPU. tests/releaseDockerPlatforms.test.cjs and the
docker cross-check in tests/releaseSnapArches.test.cjs are updated for the
decided-set design (required amd64/arm64, optional exotics, loong64 excluded).
Thanks to xet7 !
build-amd64 (and the other native jobs) call
embed-verified-node.sh .build/bundle/node node-x64 "$NODE_VERSION"
but $NODE_VERSION is the pinned MAJOR, '24', while the wekan/node fork tags its
releases by full version, v24.19.0. So the helper built the URL
https://github.com/wekan/node/releases/download/24/node-x64
which 404s, and the amd64 bundle failed at the Node embed step.
embed-verified-node.sh now resolves a bare major to the newest fork tag v<major>.x
that actually carries the asset (the same GitHub-API walk check-arch-binaries.sh
uses), and takes a full tag (with or without the leading v) as given. If no
v<major>.x release has the asset it stops with a message naming the fork asset to
build, rather than 404-ing on a bad URL.
Note: this fixes the URL; a build still needs the fork to HAVE published the
asset. As of now the fork's v24.19.0 carries only node-mac-x64/node-win32.exe/
node-win64.exe - its node-x64/node-arm64/node-mac-arm64 and the Linux arches were
dropped when the "release all missing" run replaced the assets, so those bundles
cannot build until the fork republishes them (a wekan/node-side fix).
Thanks to xet7 !
The wekan/node fork builds Node.js for 13 platforms and WeKan takes its Node.js
only from the fork, so every one of those platforms should have a WeKan .zip
bundle. Twelve did; 32-bit Windows (win32) and Intel macOS (mac-x64) did not -
the fork builds node-win32.exe and node-mac-x64 (nodejs.org has neither a 32-bit
Windows nor, going forward, a place WeKan will take Intel macOS from), but there
was no job to turn them into bundles. This adds both.
build-win32 mirrors build-win64 on a windows runner, with two differences: it
installs a 32-bit (x86) Node via setup-node so the native modules it rebuilds
(bcrypt) are ia32, matching the fork's 32-bit node.exe the bundle ships, and it
takes ferretdb-win32.exe. build-mac-x64 mirrors build-mac-arm64 on a macos-13
Intel runner, taking node-mac-x64 and ferretdb-mac-amd64.
Both are BEST-EFFORT: a preflight step checks whether the fork has published the
platform's node binary for this version and, if not, SKIPS the job with a warning
instead of a red failure - the fork builds these on its own schedule, and the
bundle appears the run after the fork publishes the binary. Every build step is
gated on that skip. release-notes waits for both so their provenance rows land in
the table. tests/releaseBundleCoverage.test.cjs pins that all six native fork
assets and all seven extra-arch legs map to a bundle; the cpu-exec and snap-arch
guards learn about the two new non-Linux bundles and the armv7 leg.
Thanks to xet7 !
The wekan/node fork builds a generic ARMv7 Node.js (node-armv7) as well as
Debian's hard-float one (node-armhf); each is a real fork platform, so each gets
its own WeKan .zip bundle. This adds an armv7 leg to build-extra-arches: it runs
in the same linux/arm/v7 emulated container as armhf, takes its Node.js from the
fork's node-armv7, and shares FerretDB's armhf binary (FerretDB has no distinct
armv7). It is best-effort (optional), like the other 32-bit ARM/x86 bundles - if
the fork's node-armv7 is ever absent for this major it skips rather than failing
the matrix job. The result is wekan-<version>-armv7.zip alongside the armhf one.
tests/releaseArchSkipAndBaseAttach.test.cjs pins armv7 as a best-effort matrix
entry.
Thanks to xet7 !
WeKan now takes its Node.js from the wekan/node fork for EVERY platform and from
nowhere else - the .zip bundles, the emulated cross-builds and the multi-arch
Docker image alike. The reason is control: the fork is Node built from source, so
a Node bug can be PATCHED and rebuilt, which cannot be done with nodejs.org's or
unofficial-builds' opaque binaries, and one source means a set of artifacts is
never half built on one Node.js and half on another depending on the CPU. The
fork already covers the CPUs the other two do not build at all (32-bit x86/ARM,
loong64, ...), so it is the only source that fits every platform.
check-arch-binaries.sh no longer walks nodejs.org -> unofficial-builds -> fork;
it lists the fork's OWN releases for this major, newest first, and takes the
newest that carries node-<arch>, verifying it against that release's
node-<arch>.sha256sum. install-node-for-arch.sh drops the official/unofficial
tarball branch and always installs the fork's bare node binary (grafting npm -
arch-independent JavaScript - from the official amd64 tarball, a build-time tool,
not the shipped node). The Dockerfile maps every TARGETARCH to a fork asset
(node-x64/arm64/ppc64le/s390x/riscv64/i386/armhf) and installs it the same way.
s390x becomes best-effort (optional) like i386/armhf/loong64: the fork has not
published node-s390x for this major yet, so the preflight SKIPS it - a warning,
exit 0 - instead of failing the whole matrix job and skipping docker with it, and
it returns on its own the release after the fork publishes node-s390x. ppc64le
and riscv64 stay required - the fork builds them reliably.
tests/releaseArchSkipAndBaseAttach.test.cjs pins the fork-only sourcing (no
official/unofficial fallback for the node binary) and the s390x skip.
Thanks to xet7 !
The docker job built and pushed the multi-arch image for all seven platforms
correctly; the step AFTER it, which inspects each registry's manifest to prove
every platform actually arrived, then failed the run over a manifest that was
right.
buildx builds linux/arm/v7, but a registry records that manifest as architecture
"arm" with variant "v7" in a separate field. The verify step's imagetools
--format printed only `{{.Platform.OS}}/{{.Platform.Architecture}}`, so the entry
read back as bare "linux/arm", and the check for "linux/arm/v7" in want= never
matched it - "wekanteam/wekan:v10.67 has no linux/arm/v7 in its manifest (it has:
... linux/arm ...)". The image was complete; the check was blind to the variant.
The --format now appends `/{{.Platform.Variant}}` when a variant is present, so
32-bit ARM reads back as linux/arm/v7, and a trailing /v8 is normalised away so
arm64 (which a registry may record as arm64/v8) still matches want=, which names
it the way everyone writes it, linux/arm64. tests/releaseDockerPlatforms.test.cjs
pins the variant-aware format and the /v8 normalisation so an OS/arch-only format
cannot come back.
Thanks to xet7 !
WeKan takes its Node.js from the wekan/node fork for EVERY platform now, not
only the arches nobody else builds. The reason is control: the fork is Node
built from source, so a Node bug can be patched and rebuilt - which cannot be
done with nodejs.org's opaque binaries - and one source means a set of bundles
is never half built on one Node.js and half on another. The fork also already
covers the arches nodejs.org and unofficial-builds do not build at all (32-bit
x86/ARM, loong64, win32), so it is the only source that fits every platform.
The four native bundles (amd64, arm64, win64, mac-arm64) embedded a verified
nodejs.org tarball; embed-verified-node.sh now downloads the fork's bare
node-<asset> binary (node-x64 / node-arm64 / node-win64.exe / node-mac-arm64)
and verifies it against the node-<asset>.sha256sum the fork publishes beside it.
That is all a bundle needs - it ships the node binary and runs `node main.js`;
the BUILD still uses the runner's own node+npm, so no npm is grafted here (the
Docker image and the emulated cross-builds, which run npm, graft it separately).
Provenance records source wekan/node and the fork's SHA256.
tests/releaseNodeVerified.test.cjs pins the fork source for all four.
The extra-arch bundles, the Docker image and a new win32 bundle move to the fork
too, in following commits.
Adds two Docker platforms that were shipping as .zip bundles only.
Base image: ubuntu:26.04 -> debian:trixie. Ubuntu publishes no linux/386
image (which is why 386 could never build); Debian does, and debian:trixie
carries every arch this image targets - amd64, arm64, 386, arm/v7, ppc64le,
riscv64, s390x - so one base covers them all, and it is the same base the
per-arch .zip bundles already build in. (The Dockerfile installs no MongoDB
server - every arch defaults to the bundle's FerretDB - so nothing here is
Ubuntu-specific; sha checks switched from `shasum` to `sha256sum`, which is
coreutils and always present.)
Node.js: nodejs.org and unofficial-builds ship no Node 24 for 32-bit x86 or
ARM, but the wekan/node fork does - as a BARE node binary (node-i386 /
node-armhf) plus a .sha256sum, not a tarball. A new `fork` branch in the arch
case downloads that binary, verifies it against its .sha256sum, and grafts npm
+ npx from the official amd64 tarball (npm is arch-independent JavaScript that
runs on the fork's node). Debian's 32-bit ARM port is armhf (ARMv7 VFPv3-D16),
which is what linux/arm/v7 runs, so node-armhf is the match.
linux/386 and linux/arm/v7 go back into the buildx --platform list and the
want= verify list. linux/loong64 stays out: no Docker base publishes it and the
registries do not agree on its manifest yet. tests/releaseDockerPlatforms.test.cjs
now checks the debian base, that 386/arm/v7 are in and loong64 out, and that
every built platform has a matching branch in the Dockerfile arch case.
NOT built here (no buildx/emulation) - confirm on CI. Depends on the fork having
published node-i386 / node-armhf and the bundle having wekan-<v>-{i386,armhf}.zip
for the release.
With linux/386 gone, the multi-arch build got past the base image and then
failed inside the Dockerfile RUN on linux/arm/v7:
+ echo Unsupported architecture: arm
+ exit 1
Docker's TARGETARCH for linux/arm/v7 is `arm`, and the Dockerfile's arch case
handles only amd64/arm64/ppc64le/s390x/riscv64 - it deliberately has no `arm`
branch, because it installs Node.js from nodejs.org / unofficial-builds and
NEITHER ships a Node 24 for armv7l (the Dockerfile comment says so). But
linux/arm/v7 was still in the buildx --platform list, so the RUN hit the
"Unsupported architecture" guard and the whole build failed.
linux/arm/v7 is removed from --platform and from the want= verify list, joining
linux/386 and linux/loong64: all three ship as .zip bundles (armhf's Node comes
from the wekan/node fork there) but not as Docker images, because the image
sources its Node from nodejs.org/unofficial only. tests/releaseDockerPlatforms.test.cjs
now pins arm/v7 out of both lists too.
With loong64 no longer skipping build-extra-arches, the docker job ran again
for the first time in a while and failed at the base image:
#2 ERROR: no match for platform in manifest: not found
ubuntu:26.04: failed to resolve source metadata ... no match for platform
The image is FROM ubuntu:26.04, and Ubuntu publishes no i386 image, so
`docker buildx build --platform ...,linux/386,...` cannot resolve the base for
that one platform and the whole multi-arch build stops - the workflow's own
bundle-build comment already notes `docker run --platform linux/386
ubuntu:26.04` answers "no matching manifest".
linux/386 is removed from the build's --platform list and from the want= list
that verifies the pushed manifest, exactly as linux/loong64 already was: i386
ships as a .zip bundle (built on debian:trixie, which has 386) but not as a
Docker image. tests/releaseDockerPlatforms.test.cjs pins that 386 and loong64
are out of both lists, that the two lists match, and that the base is
ubuntu:26.04.
Group the Upcoming entries by area, per CLAUDE.md - a subsection with more
than one entry is grouped, not loose: "Extra-architecture bundles" (loong64)
and "The Launchpad snap builds" (the flatten and the licence-hint fix). The
In short now accounts for the third entry in a closing clause.
The Launchpad project xet7-craft-remote-build is set to MIT / X/Expat, but the
snap-launchpad failure hint printed "check its Licence, 'None specified' ... set
the licence to MIT" on EVERY failure - so an s390x build that Launchpad stopped
for another reason read as a licence problem that was already fixed, and the
time went to re-checking a correct setting.
The hint now says the licence should already be MIT (re-set it only if the page
shows "None specified") and, for the real remaining case, explains that a
"Stopped" build with a BUILDING (not FAILED) log that ends mid-step is Launchpad
cancelling the build as it runs - typically an OOM in the memory-heavy Meteor
npm install, or a transient build-farm reset, both of which the three retries
already cover. The large-history push that also broke these builds is fixed
separately by the repo flatten (previous commit).
The ppc64el (and before it v10.55 riscv64) snap-launchpad build failed with
"Git operation failed with: Could not push 'HEAD' to git.launchpad.net/...
snapcraft-wekan-<hash>", ~4-5 minutes into the push, on every one of the three
attempts. snapcraft remote-build pushes the project's git repository to
Launchpad and builds it there; it rejects a SHALLOW clone (which is why the
checkout is fetch-depth: 0), but it does not need the history, and WeKan's full
history is large enough that the push times out or is refused mid-upload.
After the full checkout, the repo is now re-initialised as ONE commit of the
tagged tree: git rev-parse --is-shallow-repository is still false, so
remote-build accepts it, but the push carries the source tree (tens of MB)
instead of the whole history (hundreds). The snap version comes from
snapcraft.yaml (verified in the next step), not git-describe, so dropping the
history changes nothing about what is built. tests/releaseSnapLaunchpadFlatten.test.cjs
pins the flatten, its order, and that the checkout stays full-depth.
The docker job needs build-extra-arches, and GitHub skips a job whose needed
job failed. build-extra-arches is a matrix, and its loong64 leg hard-failed:
no debian/ubuntu image publishes linux/loong64 (node-loong64 and
ferretdb-loong64 exist, the container does not), so the preflight stopped it.
One failed matrix leg makes the whole build-extra-arches job "failure", which
SKIPPED docker - and through docker the charts, ucs and nextcloud jobs that
need it. That is what left docker skipped in run #209.
loong64 is now optional: true, like i386 and armhf, and check-arch-binaries.sh
skips a best-effort arch whose BASE IMAGE is missing - not only one whose
Node.js is missing - with a warning and exit 0 emitting skip=true, gated the
same way every other best-effort skip is. So the matrix job succeeds and docker
runs; loong64 stays visible as a skip and returns to a real build the day a
linux/loong64 base image is published.
The release job in v10.63 and v10.64 shipped with no amd64/arm64 bundle, and
every downstream snap/docker/AppImage job 404'd on wekan-<version>-amd64.zip.
The cause was not softprops (v10.63's guess) but the checkout: the job
downloaded the bundles into the workspace and THEN ran actions/checkout for the
provenance script. The workspace is not a git repo at that point, so checkout's
first act is "Deleting the contents of '<workspace>'" to make room for a fresh
clone - and it does this even with `clean: false`, which only skips the
`git clean` in an already-checked-out repo. The just-downloaded
wekan-<version>-{amd64,arm64}.zip were deleted before the attach step, which
then failed with "wekan-<version>-amd64.zip is missing or empty".
The checkout now runs FIRST, into the empty workspace, and the bundles are
downloaded on top of the checked-out tree, where nothing removes them.
tests/releaseBundlesSurviveCheckout.test.cjs now pins the ORDER (checkout
before the bundle download) rather than clean: false, which was never enough.
Thanks to xet7 !
Opening an admin report by its address - /admin/problems/files typed,
bookmarked or refreshed - drew the column headers, "No results" and a
"1 / 1" pager over data that was plainly there, while the count method
reported five. 78b2f9ebc fixed one half of this (re-subscribe once the
login lands) but the report still came up empty, because the deeper cause
was untouched.
The onCreated autorun opens the pane and subscribes when problemsOpenPane
or the logged-in user changes. It called openReportPane()/loadReport()
directly in its reactive body, so it became reactive on cfg.count -
loadReport reads it through pageInfo - and loadReport's own count method
then did cfg.count.set(...). That re-ran the autorun, and a Meteor.subscribe
made inside an autorun is AUTO-CANCELLED when the autorun re-runs; the
re-run took the "same user, same pane" branch, did not re-subscribe, and
left the report with no subscription at all - "attachments in minimongo: 0".
Reached from the menu it worked, because switchMenu opens the pane from an
EVENT, not a computation, so that subscribe was never auto-managed; only
the URL path, which runs inside the autorun, hit it.
The autorun now reads only paneId and userId reactively and runs its body
inside Tracker.nonreactive, so cfg.count.set no longer re-runs it and the
subscribe is not auto-cancelled. The subscription's lifetime is managed by
loadReport (stops the previous) and a new onDestroyed (stops the last)
instead of the autorun. tests/adminReportsSubscriptionLifetime.test.cjs
pins the nonreactive body and the onDestroyed cleanup.
Thanks to xet7 !
Repoint the Upcoming section's commit links after the version-bump rebase
moved the two code commits to new hashes:
081d50b45 -> e802d52eed and
17d7493d6 -> 55463f685a .
Two release-build failures from the v10.63 run (logs in ../log/wekan3):
1. The release shipped NO amd64/arm64 bundle, so every snap job (native,
wekan-ondra, wekan-gantt-gpl) 404'd on wekan-10.63-amd64.zip. The base
bundles were the only ones attached by the central `release` job with
softprops `files:`, which does not fail on an unmatched file - so a run
that produced no zip created a release with none and reported success,
and softprops never listed the .sha256sum either. Every other bundle
self-attaches with `gh release upload --clobber`, loud on a missing file
and verified from the release side. The release job now attaches amd64
and arm64 the same way - failing the release if a base bundle is missing
instead of 404'ing ten downstream jobs - and checksums the exact bytes it
attaches so the base bundles get a .sha256sum like the rest.
2. build-extra-arches failed on i386 and armhf: Node has no linux-x86 build
at all, and no source builds Node 24 for armv7l, so there is nothing to
build a bundle against. They are now marked best-effort (optional: true);
when its Node.js is absent everywhere, check-arch-binaries.sh emits
skip=true (a warning, exit 0) and the build steps are gated on it, so the
arch is skipped rather than failing the run. It returns on its own the
first release after wekan/node publishes node-i386 / node-armhf. A
required arch whose Node.js is missing is still fatal.
tests/releaseArchSkipAndBaseAttach.test.cjs pins both; the base-attach guard
in tests/releaseBundlesSurviveCheckout.test.cjs is updated to the new path.
Thanks to xet7 !
The four native bundles (amd64, arm64, win64, mac-arm64) shipped Node.js
with `cp $(command -v node)` - the runner's node. It has no published
checksum, so the release provenance table could only say "no checksum
published" and nobody could tell WHICH Node.js build a platform carried -
the exact thing needed when a Node.js CVE lands. Worse, build-arm64 has
no setup-node, so ubuntu-24.04-arm's DEFAULT Node 22 went into the arm64
bundle instead of the pinned Node 24, silently.
Each native bundle now downloads the pinned Node.js for its OS+CPU from
nodejs.org and verifies it against SHASUMS256.txt
(releases/embed-verified-node.sh), and verifies FerretDB against the
.sha256sum wekan/FerretDB now publishes beside every binary. Provenance
records nodejs.org + the exact version + the verified SHA256; win64 and
mac-arm64 now upload provenance too, so every platform is traceable. The
emulated arches already did this via install-node-for-arch.sh.
tests/releaseNodeVerified.test.cjs pins that no native bundle goes back
to the runner's node or an unverified download.
Thanks to xet7 !
releases/expected-assets.sh looks for WeKan-<v>-<arch>.AppImage.sha256sum and
WeKan-<v>-<arch>.flatpak.sha256sum (asset name + .sha256sum, the same convention
the wekan-<v>-<arch>.zip.sha256sum bundles use). But the builds wrote
WeKan-<v>-<arch>.sha256sum - extension DROPPED - so "what the release is missing"
never found the checksum and rebuilt the AppImage and Flatpak on every run even
when they were already published. The checksum (and md5sum) now keep the
.AppImage / .flatpak extension, matching what the check expects.
This is the root cause of the snap / docker / AppImage 404s on
wekan-<version>-amd64.zip. The `release` job downloads the per-arch bundles to
the workspace root, then checks the repo out (for the provenance script) BEFORE
creating the GitHub Release. actions/checkout defaults to clean: true, whose
`git clean -ffdx` deleted those untracked zips - so the "Create GitHub Release"
step's `files: wekan-<version>-{amd64,arm64}.zip` matched nothing. softprops does
not fail on unmatched files, so the release was created (job "success") WITH NO
BUNDLES, and every downstream job that downloads a bundle 404'd. The checkout now
sets clean: false, so the bundles survive and are attached.
Also: the snapcraft `wekan` part downloaded the bundle with a single wget that
treated a 404 as fatal, so it also broke on the brief CDN lag right after an
upload. It now retries (--tries/--retry-on-http-error), like the curl downloads
elsewhere in the release.
tests/releaseBundlesSurviveCheckout.test.cjs pins that a checkout after the
bundle download keeps clean: false; it fails on the pre-fix workflow.
The snap i386 platform was removed because core24 cannot build it; this guard
still pinned i386 as a snap platform and a Launchpad arch. Updated to expect the
six buildable snap platforms (amd64, arm64, armhf, ppc64el, riscv64, s390x) and
to treat i386 like loong64 - an arch that has a wekan-<arch>.zip BUNDLE but is
deliberately not a snap - with the reason recorded in the test.
core24 has no i386 port, so `base: core24` cannot build an i386 snap (removed in
the previous commit). Debian still ships i386 and is glibc, so - unlike an
Alpine/musl base - WeKan's existing binaries run unchanged. Since the WeKan snap
only assembles the prebuilt wekan-<arch>.zip (and wekan-i386.zip already exists),
the only missing piece for an i386 snap is an i386-capable base.
snap-base-debian/ adds `wekan-base-debian13` (type: base), a trimmed Debian
trixie rootfs, starting with i386 - which builds NATIVELY on an amd64 runner (no
qemu, no Launchpad). It is isolated from the working core24 snapcraft.yaml so it
can never break the mainstream builds.
This is a documented SCAFFOLD, not a tested artefact: a custom base snap is
advanced snapcraft and needs a real `snapcraft pack` + local install + a WeKan
snap running on top before it is wired into CI or sent for the (manual) store
review. README.md carries the full plan, the local test loop, the publish/ACL
steps, and the known pitfalls.
The v10.60 release job succeeded (the release-notes shell-injection fix landed),
but four of the per-platform build jobs failed, each for its own reason:
- build-win64: the "Check the binaries" step ran `set -euo pipefail` under the
Windows default shell (PowerShell), which answered "A parameter cannot be found
that matches parameter name 'euo'". It is a bash script; it now says
`shell: bash`.
- build-mac-arm64: the zip step ran `sha256sum`, which does not exist on macOS
(that is the GNU coreutils name), so the job died with exit 127. Use
`shasum -a 256`, the BSD/macOS spelling, which writes the same format.
- build-extra-arches: the job downloaded wekan-<version>-amd64.zip but never
unpacked it, so the container mounted an EMPTY /bundle and the native-module
rebuild died with "ENOENT ... /bundle/programs/server/package.json" (exit 254).
Added the unzip the arm64/win64/mac-arm64 jobs already do.
- snap-native / snap-launchpad: snapcraft.yaml declared an i386 platform, but
core24 (Ubuntu 24.04) has no i386 port, so snapcraft rejected it with "trying
to build snap in one of ['i386'], but none of these build architectures are
supported" - a PARSE error that failed EVERY snap build, not only i386's.
Removed the i386 platform and its snap-launchpad matrix entry; i386 users are
served by the .deb and AppImage, and an i386 snap is not buildable on a modern
base.
loong64 still reports its documented "debian:trixie has no linux/loong64 image"
gap (there is no loong64 userland to rebuild in), which is intentional and left
visible.
These jobs do not build WeKan from source — they download the prebuilt release
zip (wekan-<version>-<arch>.zip) that release-all.yml publishes, and fail hard
if it is missing. So they failed whenever they ran before the release was
complete: dockerimage.yml / docker-publish.yml on a push or a tag pushed early
in the release, and AppImage.yml when release-all-missing asked it to wrap a
version whose base bundle was not published (`gh release download` → "no assets
to download"). The v10.59 release itself had failed (the release-notes shell
injection, fixed separately), so every one of these downstream jobs 404'd.
Each now checks whether the release asset it needs is published and SKIPS with a
notice (a green run) when it is not, instead of failing:
- dockerimage.yml / docker-publish.yml gate the image build on the pinned
version's amd64 zip being on the release;
- AppImage.yml flips its per-arch BUILD_THIS off when that arch's bundle is
absent, and its publish job treats "no AppImage, and no base bundle either" as
nothing-to-do rather than an error.
The real images/AppImages are still built by release-all.yml's own jobs once the
release is up; these standalone/gap-filling runs simply stop failing in the
meantime.
The release and release-notes jobs built the GitHub Release body with
`printf '%s\n' "${{ needs.prepare.outputs.changelog }}"`. Interpolated inline,
the CHANGELOG becomes part of the shell source before bash parses it, so every
backtick in it runs as a command. The v10.59 notes are full of `code` spans, so
the release job failed with "Incorrect: command not found",
"loginFailureDecision.js: Permission denied", "POST: command not found" and
published no release. The changelog now reaches both steps through the
environment (env: CHANGELOG), and the scripts use "$CHANGELOG", where the shell
treats it as data and never parses its backticks, $( ) or quotes.
tests/releaseNotesNoShellInjection.test.cjs pins that outputs.changelog is only
ever consumed as an env: assignment, never inline in a run: script; it fails on
both pre-fix printf lines.
Thanks to xet7.
# for WeKan to answer on its port, then stop it - so a broken bundle
# is caught here rather than by whoever downloads it.
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. The 32-bit builds are
# packed on a 64-bit runner, which executes them only if the kernel and
# loader for that word size are present - GitHub promises neither. If
# the binary cannot be executed AT ALL, the AppImage is as good as
# anything here knows and the release must not be withheld for it; say
# so and move on. Anything else - it starts and does not answer - is
# the bug this step exists for and still fails.
if ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --appimage-help >/dev/null 2>&1 \
&& ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --help >/dev/null 2>&1; then
if ! file "$app" | grep -q "$(uname -m)"; then
echo "::warning::this runner cannot execute a ${{ matrix.appimage_arch }} binary, so the AppImage was packed but not started. It is uploaded unchecked."
echo "::notice::No base bundle (wekan-${{ needs.release.outputs.version }}-{amd64,arm64}.zip) is on ${{ needs.release.outputs.tag }} yet, so there is nothing to wrap - skipping. release-all.yml builds the AppImages once the base bundles are published."
exit 0
fi
echo "::error::A base bundle is published but no architecture produced an AppImage - every build job failed. Their logs say why; nothing is uploaded."
exit 1
fi
for want in x86_64 aarch64; do
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
echo "::warning::no AppImage for $want - see that build job's log"
if ! NODE_META="$(bash /tmp/resolve-node-source.sh "${WEKAN_ARCH}""${NODE_MAJOR}")";then
echo"No Node.js ${NODE_MAJOR}.x for ${WEKAN_ARCH} at nodejs.org, unofficial-builds.nodejs.org or wekan/node-patches, so this image cannot be built for it. Build node-${WEKAN_ARCH} in wekan/node-patches, or drop ${TARGETARCH} from the image's platform list." >&2
echo ==^> Running Playwright Chromium, Firefox and WebKit one at a time^(--workers=1^) on Meteor #1 [Node.js :3000, MongoDB :3001]. Full log: %RUN_LOGDIR%\wekan-alltests-browsers.log
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status";;
"git pull|git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts"\
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile";;
"Dev server")
choose "Dev server"\
"localhost:3000|Run Meteor for dev on http://localhost:3000"\
@@ -1923,7 +2136,7 @@ while [ -z "$opt" ]; do
"Kill all dev servers|Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)";;
"Tests")
choose "Tests"\
"EVERYTHING (sequential): WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/"\
"EVERYTHING (sequential): the guard + WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/"\
"WeKan's own tests only, parallel|Run WeKan's own tests in parallel on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, concurrently. No database conformance and no FerretDB tests - logs in log/<datetime>/"\
"WeKan's own tests only, sequential|Run WeKan's own tests sequentially on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, one job at a time. No database conformance and no FerretDB tests - logs in log/<datetime>/"\
"Mocha (server-side)|Test Mocha unit + security + API-logic tests (server-side only, no browser)"\
@@ -2052,8 +2265,13 @@ for _once in 1; do
break
;;
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
update_git
"git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts")
git_pull
break
;;
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
git_push
break
;;
@@ -2261,39 +2479,15 @@ for _once in 1; do
echo"Ensuring .eslintrc.json includes @typescript-eslint plugin and no-floating-promises rule"
echo"OK: Rule @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
else
echo"WARNING: Rule @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json"
echo"Suggested: add @typescript-eslint/eslint-plugin and set '@typescript-eslint/no-floating-promises': 'error'"
fi
echo"Quick note: @typescript-eslint/no-floating-promises is a type-aware rule and may require further parser/project setup for full enforcement in all files."
echo
echo"Scanning for unawaited Authentication.checkBoardAccess/checkBoardWriteAccess in server/models"
if rg -n "$AUTH_CALL_PATTERN" server/models | rg -v "$AUTH_AWAIT_PATTERN";then
echo"WARNING: Found possible unawaited board auth checks above"
else
echo"OK: No unawaited board auth checks found"
fi
else
if grep -RInE "$AUTH_CALL_PATTERN" server/models | grep -vE "$AUTH_AWAIT_PATTERN";then
echo"WARNING: Found possible unawaited board auth checks above"
else
echo"OK: No unawaited board auth checks found"
fi
fi
# The checks themselves - the same function EVERYTHING runs, so the
# menu option and the whole-run stage can never drift apart. Above
# this line is the setting-up half, which only a person asking for
# this option gets: it installs packages and writes the rule into
# .eslintrc.json, and a test run must do neither.
floating_promises_checks
} 2>&1| tee "$LOG"
break
;;
@@ -2320,7 +2514,7 @@ for _once in 1; do
fi
;;
"Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
"Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
run_everything
;;
@@ -2410,5 +2604,21 @@ for _once in 1; do
break
;;
# No arm matched. $opt is the menu entry's FULL DESCRIPTION, written twice -
# once in the choose() list far above, once as the arm here - so an edit to
# the menu text that misses the arm leaves an option that matches nothing.
# Without this, the case simply falls through, the `for _once` loop ends and
# the script EXITS: the option looks like it worked, printed nothing and ran
# nothing. That is what renaming "EVERYTHING (sequential)" did to it.
# tests/buildScriptParity.test.cjs fails on the mismatch itself; this is what
# the person in front of the menu sees if one ever gets past it.
*)
echo"build.sh: no handler for this menu option - that is a bug in build.sh." >&2
echo" The menu entry's description and its case arm must be the SAME string:" >&2
echo"$opt" >&2
echo" Nothing was run. Please report it, or run the same thing from the command line:" >&2
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.59-win64.zip](https://github.com/wekan/wekan/releases/download/v10.59/wekan-10.59-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.78-win64.zip](https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
@@ -70,6 +70,86 @@ problem *observable* to WeKan; the recording is WeKan's job. Concretely:
WeKan maps these to `eventlog` documents via the category catalog
(`models/lib/securityCategories.js`) using `source:'sqlite.*'`.
---
## 3b. Canary tokens — operations the client never issues
The database is reached over a **local socket by one application**, whose driver
is a Meteor 3 one. That makes a class of operations interesting **by their mere
presence**: server-side JavaScript evaluation, an aggregation writing its result
into a collection, dropping a database, a server-administration command. The
driver does not send them. A request that does is either a bug or somebody who
has reached the socket and is looking around — and both are worth telling the
operator about.
`internal/util/canary` (paired with `tests` in the same package) is the FerretDB
half of [WeKan.md §12](WeKan.md), and it keeps the same three properties:
- **SILENT.** A tripped canary returns the ordinary *"operation not supported by
this build"* refusal — the same answer an unimplemented command gets. The id is
appended as `canary:<id>`, which the client parses and the operator reads;
nothing in it says that anything was detected or recorded, so a probe cannot
tell a watched operation from an unimplemented one and route around the watched
ones. A Go test asserts the message contains none of *detect*, *record*, *log*,
*alert*.
- **BOUNDED.** The package writes **nothing** — no file, no table, no counter. It
is a map lookup and an error value, so a caller hammering it in a loop costs
this process one string comparison per request and this package **no memory at
all**. This is what §2's "FerretDB does not write to any database or file"
means for canaries, and a Go test asserts the package is stateless.
- **ATTRIBUTED.** The marker names *which* canary, so the operator's report says
"tried to run server-side JavaScript" rather than "an error".
The canaries:
| Id | Operations | Why the client never sends it |
| --- | --- | --- |
| `db.javascript` | `eval`, `$where`, `$function`, `$accumulator`, `mapReduce` | the driver has no feature that evaluates JavaScript in the database |
| `db.result-to-collection` | `$out`, `$merge` | aggregation results are read, never persisted by the database |
| `db.drop-database` | `dropDatabase`, `dropAllDatabases` | the application drops collections it owns; dropping the database is an operator action taken with the database's own tools |
| `db.server-admin` | `shutdown`, `setParameter`, `getParameter`, `profile`, `logRotate` | these manage the server, not the data |
`Check(op)` returns `nil` for everything else, and a Go test pins the ordinary
**Sanitization.** Sanitizing is routine — a filename gets trimmed, a pasted
`<b>` gets stripped from a comment — and routine is not worth an admin's
attention. What is worth it is sanitization that removed something that would
have **done** something:
| Canary | Fires when |
| --- | --- |
| `sanitize.dangerous-filename` | an uploaded name carried an exploit pattern, invisible characters or URL encoding |
| `sanitize.path-traversal` | a file path tried to leave its directory |
| `sanitize.dangerous-content` | active markup was removed from an uploaded file (a script or `javascript:` URI inside an SVG) |
| `sanitize.dangerous-text` | active markup was removed from submitted text (a comment carrying `<script>`, an `onerror=`, a `javascript:` URI) |
`removedActiveMarkup()` decides the last two by comparing what the sanitizer took
**out**: text that never had any is not interesting, and text that still has it
was not sanitized at all — which would be a different bug.
**Other common attacks.**
| Canary | Fires when |
| --- | --- |
| `spoof.forwarded-header` | `/metrics` denied a request that carried `X-Forwarded-For` — nothing legitimate sends one to an endpoint that does not trust it |
| `brute.login-lockout` | a REST login was refused while the address was locked out, i.e. it had already failed the configured number of attempts in a row |
Both replaced a **direct `securityLog.record()`**. That matters for more than
tidiness: those two paths are **unauthenticated**, so a bare `record()` was one
database insert per request an attacker chose to send. Going through the canary
gives them the §12.2 rate limit and the §12.3 attribution, and the responses —
the 401 and the 429 — are untouched.
## 13. Filesystem storage integrity, crashes and downtime
§12 watches what somebody **tries to do through WeKan**. This section is about
what happened to WeKan's own files and process **when nobody was asking it
anything**.
WeKan's attachments and avatars are files under `WRITABLE_PATH`, and the database
holds one document per file. **Nothing checked that the two still agree.** A file
can be replaced, truncated, back-dated or deleted by anything that reaches the
filesystem — a bad restore, a sync tool, a container rebuild, a shell on the
volume — and WeKan would keep serving whatever is there now, silently.
### 13.1 The baseline, and the four hashes
One document per file in the existing WeKan database (`fileIntegrity`; **no new
files under `WRITABLE_PATH`**, per §3): the path, the size, the modification
time, and **md5, sha256 and sha512**.
Three digests, not one, because they answer different questions. **md5** is fast
and is what most other tools print, so an admin can compare against a backup with
the tool they already have — it is never alone here, because its collision
resistance is gone. **sha256** is the working digest. **sha512** is a second,
different-width one, and it earns its place on the day the two *disagree*: two
digests over the same bytes cannot disagree, so when they do, the bytes were not
read the same way twice — a failing disk, a partial write, a truncated copy —
which is a different problem from a substitution and is reported as its own,
**critical**, finding.
**ed25519 is the fourth, and it is not a hash — it is a signature**, which is the
only thing that answers the question the three digests cannot: *who says these
are the right hashes?* Anybody who can rewrite a file can rewrite a row of
hashes. So each baseline entry is signed over a canonical line
(`manifestLine()` — fixed field order, no JSON, whose key order and escaping are
not guaranteed stable across versions), and the signature is verified on every
scan. A record that does not match its signature is a **critical** finding, and
it is never explained away by an ordinary edit: WeKan does not rewrite a baseline
without re-signing it.
The key:
| Source | What it protects against |
| --- | --- |
| `WEKAN_INTEGRITY_PRIVATE_KEY` (PKCS#8 PEM), supplied by the operator and **never stored by WeKan** | an attacker who reaches the **database** as well as the disk |
| otherwise, generated on first run and kept in the database | filesystem-only tampering — a restore, a sync tool, a container rebuild, a shell on the volume |
The second is the common case and is honest about not being more than that. A
**malformed** supplied key is reported and the scan runs **without** signing
rather than silently falling back to a generated one, which would look like it
was working while checking nothing the operator meant.
### 13.2 The scan: once a day, when nothing else needs the machine
Reading every stored byte is the last thing that should compete with users, so
the scan is paced by policy rather than by hope (`models/lib/fileIntegrity.js`,
pure and time-injected):
- **once a day** (`intervalMs`), checked hourly — not on every restart;
- **not while the machine is busy**: at or above **60% CPU** it does not start,
and it stops if the load rises mid-run. It is never urgent;
- **a pause between every file** — 50 ms, plus 20 ms per megabyte, so a
directory of large files does not become a sustained read;
- **a time budget** (15 minutes). A scan that stops and continues tomorrow is
better than one that holds a machine down;
- **one read per file for all three digests.** Three passes would be three times
the disk for the same bytes.
A run that stopped early does **not** report the files it never reached as
missing — it has not looked everywhere, and saying otherwise would be a lie.
### 13.3 The finding: changed, with no record saying why
Files change when people use WeKan; reporting that would be noise. So each change
is classified against WeKan's own record of the file:
| Situation | Result |
| --- | --- |
| unchanged | nothing recorded |
| changed, and WeKan has a record of the operation | `info` — reported once, then **re-baselined** so it does not repeat daily |
| **changed, with nothing to account for it** | **the warning** — at least `medium`, and deliberately **not** re-baselined, so it keeps showing until somebody looks |
| the record's signature does not verify | `critical`, never explained |
| recorded and now missing | `high` |
| present and never recorded | `low` — a leftover or a backup copy is not evidence of anything |
A modification time that moved **backwards** is called out separately: nothing
does that by accident.
The event carries the **file's name and the finding, never its contents and never
the digests** — an admin needs to know which file, not to read it here.
### 13.4 Crashes, downtime and errors nobody caught
Admin Panel → Problems answered "what did WeKan refuse" and "what did the
database say", and not the plainest question: **did this server stop, and did it
stop cleanly?**
A crash leaves nothing behind — the process is gone, so it cannot write a message
about being gone. A **heartbeat** is the only thing that works: WeKan writes the
time every minute, and the NEXT start reads it.
| What the next start finds | Recorded as |
| --- | --- |
| no previous run | nothing — a first run is not a problem |
| last heartbeat with a **clean-shutdown mark** | nothing — a deliberate restart is not a problem |
| a **short** gap, no mark | `low` — the shutdown hook may simply not have run |
| a **long** gap, no mark | **`high`** — it stopped without shutting down cleanly, and here is how long it was down |
Recording every deliberate restart is how a Problems page becomes a page nobody
opens, which is why the first two record nothing at all.
Two more land in the same stream, because they are the same question one level
down: an **uncaught exception** and an **unhandled promise rejection**. This app
turns the second into a process exit, so it is a crash *with a cause attached* —
and the cause is worth recording before the process goes. The listeners are
**added, never replaced**: removing whatever else is listening would change how
the app handles its own failures.
### 13.5 Where it appears
A new event stream, `integrity`, in the same `eventlog` collection and the same
Admin Panel → **Problems → Filesystem integrity** table as the others (§8), with
the Username / IP address / Attempts columns §12.3 added. It counts towards the
red **Problems** button like every other stream.
The `fileIntegrity` baseline itself is **never published and never client-writable**:
it is a map of every file on the server, and the key document is a private key.
Neither belongs on a client, admin or not — Admin Panel reads the **findings**,
which are ordinary event rows.
---
## 14. Out of scope / follow-ups
- Rotating/retention: a cron trims `eventlog` to the newest N per stream (`remove` older docs).
- Shipping logs to an external SIEM (kept local by design).
"set-selected-unstarred":"Уклони звездицу са изабраних табли",
"set-selected-home":"Постави да се прво ови списи наметну након пријаве",
"home-board-badge":"Наметнути списи који се први приказују после пријаве",
"home-board-empty":"Drag a board here to open it after login",
"set-selected-starred":"Додели им звездице",
"set-selected-unstarred":"Уклони им звездице",
"set-selected-home":"Нек ме дочека",
"home-board-badge":"Списи добродошлице",
"home-board-empty":"Овде сместите списе за добродошлицу",
"home-board-remove":"Remove from Home",
"home-board-remove-confirm":"Remove this board from Home? The board itself is not deleted.",
"activity-dueDate":"је изменио крајњи рок на %s у предмету %s",
@@ -162,8 +162,8 @@
"added":"додао",
"addMemberPopup-title":"Пријем сарадника",
"memberPopup-title":"Избор сарадника",
"admin":"Управник",
"admin-desc":"Има увид и пун приступ у све предмете из ових списа. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
"admin":"Водећи сарадник",
"admin-desc":"Има пун увид у све предмете из ових списа. Уредник је како садржаја списа тако и делова садржаја предмета. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
"admin-announcement":"Јавни разглас",
"admin-announcement-active":"Пусти на јавни разглас",
"error-board-doesNotExist":"Овакви списи не постоје",
"error-board-notAdmin":"Да би то урадили, треба да будете управник ових списа",
"error-board-notAMember":"Да би то урадили треба да будете сарадник на овим списима",
"error-board-notAdmin":"Да би то урадили, треба да постанете водећи сарадник ових списа",
"error-board-notAMember":"Да би то урадили за почетак треба да постанете сарадник на овим списима",
"error-notAllowed":"Ваша улога на табли не дозвољава то",
"error-json-malformed":"То што сте укуцали нема исправан JSON облик",
"error-json-schema":"Ваши подаци у JSON облику не укључују исправне информације у предвиђеном облику",
@@ -719,7 +719,7 @@
"label-delete-pop":"Пажња. Опозив ове радње неће бити могућ! Изабрана налепница ће бити одлепљена/уклоњена из свих предмета и њена историја ће исчезнути.",
"labels":"Налепнице",
"language":"Језик",
"last-admin-desc":"Не можете да мењате задужења јер мора постојати барем један управник/администратор.",
"last-admin-desc":"Не можете да промените улогу јер мора преостати барем један водећи сарадник.",
"leave-board":"Раздужи све предмете",
"leave-board-pop":"Да ли сте сигурни да желите да раздужите све предмете из списа __boardTitle__? Бићете изузети из свих тих предмета.",
"leaveBoardPopup-title":"Раздуживање",
@@ -733,7 +733,7 @@
"settingsUserPopup-title":"Рад са сарадницима",
"settingsTeamPopup-title":"Рад са правним тимовима",
"normal-desc":"Може да има и увид и пун приступ у све предмете из ових списа. Не може да поставља правила рада у овим списима.",
"normal-assigned-only":"Спољни виши сарадник",
"normal-assigned-only-desc":"Има увид и пуна права само у оним предметима где има надлежност или је опуномоћен.",
"normal-desc":"Има потпуно иста права као и водећи сарадник уз разлику да не може да поставља правила рада у овим списима.",
"normal-assigned-only":"Сарадник",
"normal-assigned-only-desc":"Има потпуно иста права као и виши сарадник уз разлику да је сараднику одузето право да уопште има увид у предмете за које није задужен.",
"not-accepted-yet":"Позив још увек није прихваћен",
"notify-participate":"Примајте допунске извештаје при било којој измени предмета које сте сами завели или где сте сарадник",
"notify-watch":"Примајте допунске извештаје при било којој измени списа, делова поступака или предмета које пратите",
@@ -1000,9 +1000,9 @@
"org-domains":"Домени ове организације",
"org-domains-description":"Имена домаћина на којима се послужује ова организација, раздвојена зарезима — на пример a.example.com, kanban.example.org. Захтев ка било ком од њих јесте захтев ове организације, а визуелни идентитет испод замењује идентитет инстанце. Важи само ако је сервер покренут са MULTITENANCY=true. Оставите празно ако ова организација није закупац.",
"error-org-domain-taken":"Тај домен већ припада другој организацији:",
"org-admins":"Администратори организације",
"org-admins-description":"Чланови ове организације који смеју да је администрирају: њене људе и њене резервне копије, и ништа друго. Никада не могу да доделе овлашћење администратора целог сајта нити да управљају администратором сајта.",
"org-admin":"Администратор организације",
"org-admins":"Одговорна лица странке",
"org-admins-description":"Постављени појединци који сами могу да праве сарадничку мрежу странке и њен резервни примерак података. Нису део одлучивања око управе.",
"org-admin":"Одговорно лице",
"team-shared-templates":"Дељени шаблони",
"team-propagate-members-to-boards":"Пренеси чланове на табле",
"team-sync-members-from-auth":"Синхронизуј чланове са провајдера аутентификације",
"boardMinicardSettingsPopup-title":"Рад са омотима предмета",
"deposit-subtasks-board":"Депонуј издвојене послове у списе:",
@@ -1283,7 +1283,7 @@
"display-authentication-method":"Прикажи метод за утврђивање идентитета",
"oidc-button-text":"Измени OIDC текст на дугмету",
"default-authentication-method":"Уобичајени метод за утврђивање идентитета",
"duplicate-board":"Умножи списе у још један примерак",
"duplicate-board":"Умножи један примерак",
"duplicate-board-confirm":"Да ли сте сигурни да желите да направите још један примерак списа?",
"org-number":"Број евидентираних странака је:",
"team-number":"Број правних тимова је:",
@@ -1352,21 +1352,21 @@
"allow-rename":"Дозволи преименовање",
"allowRenamePopup-title":"Дозволи преименовање",
"roles":"Улоге",
"roles-info":"Изаберите које улоге на табли смеју да изврше сваку радњу. Корисници администраторског панела (глобални администратори) увек имају сва права и не могу се овде ограничити.",
"allow-invite-to-board":"Дозволи позивање на таблу",
"all-board-members":"Сви чланови табле",
"board-admin":"Администратор табле",
"roles-status":"Стање улога",
"roles-status-desc":"Шта свака улога табле сме. Само за читање: прати поља за потврду изнад, па приказује избор пре чувања.",
"roles-info":"Одредите она лица односно оне улоге којима се дозвољава вршење наведене радње. Управници свакако имају неограничена права која им се овде не могу ограничити.",
"allow-invite-to-board":"Ко има дозволу да буде позивар",
"all-board-members":"Било који сарадник",
"board-admin":"Водећи сарадник",
"roles-status":"Опис улога",
"roles-status-desc":"Описује шта свака улога значи. Исписане вредности се не могу мењати - оне вам само помажу при избору.",
"roles-status-role":"Улога",
"roles-status-invite":"Позив на таблу",
"roles-status-sees":"Види картице",
"roles-status-comment":"Изнеси мишљење",
"roles-status-write":"Прављење и уређивање",
"roles-status-manage":"Подешавања табле",
"roles-status-invite":"Позива",
"roles-status-sees":"Има увид у предмете",
"roles-status-comment":"Учествује у расправи",
"roles-status-write":"Мења садржај",
"roles-status-manage":"Ради поставку списа",
"roles-status-sees-all":"Све",
"roles-status-sees-assigned":"Само додељене",
"roles-status-empty":"Нема улога табле.",
"roles-status-empty":"Нису подељене улоге",
"start-day-of-week":"Постави који дан се рачуна за почетак недеље",
"monday":"Понедељак",
"tuesday":"Уторак",
@@ -1836,7 +1836,7 @@
"text":"Изворна реченица",
"translation-text":"Превод",
"show-subtasks-field":"Прикажи поље издвојени послови",
"show-week-of-year":"Прикажи редни број недељуу години",
"show-week-of-year":"Прикажи редни број недељеу години",
"convert-to-markdown":"Претвори у структуирани текст",
"import-board-zip":"Додај .zip архиву која има списе у json облику и фасцикле по имену списа где је предметна грађа",
"collapse":"Скупи",
@@ -2154,7 +2154,7 @@
"migration-successful":"Обнова је успешно окончана",
"migration-failed":"Обнова није била успешна",
"migrations":"Радионица",
"migrations-admin-only":"За обнову ових списа надлежан је једино онај ко је њихов управник",
"migrations-admin-only":"За обнову ових списа надлежан је једино онај ко је њихов водећи сарадник",
"migrations-description":"Овде може да се изврши провера целовитости података и поправка оштећења. Сваки поступак обнове се може спровести независно један од другог",