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.
The binary pre-check `bash releases/require-binaries.sh`, wired into the build
jobs, needs the repo on disk, but three jobs did not have it there:
- build-arm64 and build-mac-arm64 download a prebuilt bundle artifact and never
checked the repo out at all, so the "Check the binaries ..." step died with
exit 127 ("releases/require-binaries.sh: No such file or directory") before a
bundle was ever assembled. Each now checks the tag out first, before
download-artifact drops the bundle into the same workspace.
- build-win64 checks the repo out into src/ (for start-wekan.bat and
snapcraft.yaml) but called the script at the workspace root. It now calls
src/releases/require-binaries.sh, the same src/ path its start-wekan.bat copy
already uses.
tests/releaseBuildJobsCheckout.test.cjs pins, for every build job that runs
require-binaries.sh, that the job checks the repo out where the call looks for it
(root vs src/) and that a root checkout precedes download-artifact so it cannot
wipe the downloaded bundle. It fails on all three pre-fix breakages.
Thanks to xet7.
GHSA-2g94-9x3m-hv37. Two verified weaknesses on the login path chained into
account takeover, and both are fixed here.
1. The bundled wekan-accounts-lockout brute-force protection was completely
inert. Its two Accounts.validateLoginAttempt hooks decided whether an attempt
failed by comparing the error's reason string ('Incorrect password' /
'User not found'), but Meteor's accounts-base ships ambiguousErrorMessages
defaulting to true, so _handleError rewrites every credential failure to
"Something went wrong. Please check your credentials." before those hooks run.
The literals never matched, both hooks returned early, the failure counter
was never incremented, and no account ever locked. The hooks now decide from
the attempt's structural fields (new loginFailureDecision.js): any genuine
password failure counts, while the benign 'no-2fa-code' step (password already
correct, second factor still to come) never counts, so 2FA users are not
locked out of their own sign-in.
2. The login path leaked which accounts exist by timing: a real user runs bcrypt
(~50 ms) while a missing one returns in ~2 ms. A dummy bcrypt comparison
against a fixed cost-10 hash (loginTimingDefense.js) equalises the missing-user
path on the DDP login method (loginTimingNormalization.js, a handler that runs
ahead of the built-in one and never authenticates) and on the REST endpoint.
The REST POST /users/login twin also enumerated by message (a distinct
"...not found." string) and never ran the lockout hooks at all. It now fails
missing-user and wrong-password identically, runs the timing equaliser, and
throttles failed attempts per client address (loginAttemptThrottle.js), keyed
safely behind HTTP_FORWARDED_COUNT, resetting on success.
Tests: loginFailureDecision, loginTimingDefense, loginAttemptThrottle and
loginBruteForceEnumerationWiring (four new plain-Node suites).
Thanks to NinjaGPT and xet7 !
Fixes GHSA-2g94-9x3m-hv37.
`uses: ./.github/workflows/X.yml` fails at STARTUP - taking the calling
workflow down with it, so nothing runs and there is no job log to read - when
X.yml does not exist, has no `workflow_call` trigger, or is handed a `with:`
key it never declared. That is the same class of failure as the job-level
`if:` reading matrix, and just as invisible: the YAML is valid, and only
GitHub's loader objects.
It is worth pinning now because release-all-missing.yml calls AppImage.yml
and Flatpak.yml here, TSC's calls five workflows, and the `only` input they
all take was added by hand to each of them. One typo in a `with:` key would
stop the whole run with "error parsing called workflow".
Parsed by indentation rather than with js-yaml, which is not a dependency of
this repository. These files are written in a fixed shape, and the two things
this needs - which workflows a job calls with which `with:` keys, and which
inputs a workflow declares under workflow_call - are read from it directly.
Verified against all three shapes by breaking each in turn:
passes 'onlyy' which AppImage.yml does not declare
calls missing Nope.yml
AppImage.yml has no workflow_call
and checked across every repository first - TSC, wekan, both variants, node,
FerretDB, mongo-tools and gitea - where all reusable-workflow calls already
match.
Thanks to xet7 !
The `only` filter I added to the build workflows was written as a job-level
condition:
if: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.arch) }}
GitHub refuses to load a workflow that does this:
Invalid workflow file
(Line: 109, Col: 9): Unrecognized named-value: 'matrix'
`matrix` is available to a job's runs-on, env, name, container, services,
continue-on-error, timeout-minutes, strategy and steps - but NOT to
`jobs.<id>.if`, which is evaluated before the matrix is expanded. It looks
entirely reasonable, which is why it was written in five workflows in one
sitting.
It is worse than a job that does not run: a workflow that will not load takes
every workflow that CALLS it with it, so TSC's release-all-missing.yml failed
at startup with "error parsing called workflow" and built nothing at all.
The decision moves to the job's `env:`, which CAN see matrix, and every step
asks `if: ${{ env.BUILD_THIS == 'true' }}`. Steps that already had a
condition keep it, ANDed inside parentheses - matrix.emulated,
matrix.mode == 'windows' and the rest are unchanged.
Fixed in AppImage.yml and Flatpak.yml here, and in the same commit's worth of
changes in TSC (Deb, AppImage, Flatpak, Windows, Mac), wekan/node,
wekan-ondra and wekan-gantt-gpl. Twelve files.
tests/workflowExpressions.test.cjs is the guard, and it exists because a YAML
parser is perfectly happy with every one of these: the file is valid YAML and
only GitHub's expression evaluator rejects it, when the workflow is
dispatched. It pins that no job-level `if:` reads matrix or steps, that every
${{ }} is closed, and that a workflow declaring an `only` input actually
consults it - a filter that is declared and ignored would quietly build
everything, which is the failure that looks like success.
Verified by putting the bad line back: the guard fails with
"job-level if: cannot see matrix" and names AppImage.yml:107.
The closed-brace check counts differently than it first did, because three
real lines broke it: these workflows run docker with Go templates
(--format '{{range .Manifest.Manifests}}...'), which are full of `}}` that
close nothing of GitHub's. It strips complete expressions and looks for a
leftover opener instead of counting braces.
Thanks to xet7 !
release-all.yml is one run that bumps the version, tags, builds every
platform, and publishes Docker images, snaps, the website and the charts. It
is the right thing for MAKING a release and the wrong thing for FINISHING
one. When the v10.57 run ended with all six extra architectures missing -
four on a shell-quoting bug, two on a base image that is not built for their
CPU - there was no way to get those six except to run the whole thing again,
version bump and all.
This finishes a release that already exists. It never bumps a version, never
tags, and never touches Docker, snap, the website or the charts.
What makes it possible: only build-amd64 runs Meteor. Every other bundle
WeKan ships is that same bundle with its native modules rebuilt, its Node.js
swapped and its database binaries replaced. So this downloads the PUBLISHED
wekan-<v>-amd64.zip - verifying its checksum, since it is the bundle every
other architecture is cut from - and repacks it with
releases/repack-bundle-for-arch.sh, the same script release-all.yml's
build-extra-arches runs. A bundle added to a release months later is
therefore built exactly like the ones already on it.
It builds the six extra architectures and calls AppImage.yml and
Flatpak.yml, which are reusable workflows now and take the same `only`
filter.
It does NOT claim to build everything, and says which: amd64 is the Meteor
build, arm64/win64/mac-arm64 each need their own kind of runner and their own
steps, and the Sandstorm .spk is signed. When one of those is missing the run
names it and points at release-all.yml, as a warning rather than a failure.
What a complete release looks like is releases/expected-assets.sh, in one
place, with the kind of each asset saying which build makes it. Present means
the .sha256sum is there too - a bundle whose checksum upload failed is
half-published - except the .spk, which is signed rather than checksummed.
Verified against the real v10.57 release: every one of its five assets
matches a name this predicts, with nothing left unaccounted for. Three
states, through the plan's own logic:
v10.57 as published 6 extra + 4 AppImage/Flatpak to build; amd64, arm64,
win64 and mac-arm64 reported as needing
release-all.yml (they predate the checksums, which is
noted in the script)
only the 6 extra extra=[all six], appimage=[], flatpak=[], nothing
arches missing manual - the case this exists for
complete release everything empty, nothing built
Thanks to xet7 !
Opening a 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. Reached by clicking the menu entry it worked,
so this only ever happened to the URL.
A full page load resumes the login from localStorage ASYNCHRONOUSLY, and the
route sets `problemsOpenPane` before that lands. The subscription was
therefore made with no user; the publication's isAdmin check answered
`this.ready()` with no rows; and nothing re-subscribed, because the autorun
that opens the pane did not depend on the user. The count METHOD, called
later from the same page, happily reported five - which is what made this
look like a publication bug rather than a timing one.
The autorun depends on Meteor.userId() now. openReportPane() returns early
when the pane is already open, so re-running it after the login would do
nothing at all - hence the second branch, which re-subscribes the report
that is already open now that there is a user to subscribe as.
Found by the e2e test's own diagnosis, added in the previous run for exactly
this purpose: "attachments in minimongo: 0; report_pages index: no
report-files index doc" - neither half arrived, which of the three
possibilities it lists is the one meaning the publication returned early or
never ran.
ALSO, a release-build bug of my own from earlier today:
build-extra-arches had no actions/checkout. It calls
releases/check-arch-binaries.sh and releases/record-provenance.sh and mounts
$(pwd)/releases into the container - none of which exist without one, so
every one of the six extra architectures would have stopped at "No such file
or directory". The checkout goes before the artifact download, because
actions/checkout cleans the workspace and would remove the bundle.
And releases/repack-bundle-for-arch.sh: the amd64 bundle turned into one
architecture's zip, lifted out of that job so it is one script rather than
one job's inline steps. Only build-amd64 runs Meteor; arm64, win64,
mac-arm64 and the six extra architectures are all this same bundle with its
native modules rebuilt, its Node.js swapped and its database binaries
replaced. That is what lets a release be completed one architecture at a
time without rebuilding the rest, and it had better be the same script or a
bundle added later would differ from the ones beside it.
Thanks to xet7 !
On a phone the left menu was drawn 260px wide inside a 157.5px grid track
and lay OVER the board icons. The grid itself was right: 42% of a 375px
phone is 157.5, plus the 8px gap puts the board column at x=165.5, which is
exactly where the boards start. The menu simply did not fit in its track.
260px is --wekan-left-menu-width, the DESKTOP width the drag-grip sets -
leftMenu.js does not even offer the grip below 800px. The menu carried it on
a phone anyway, because the phone rules never said otherwise and the
`max-width: 100%` on the base rule does not do what its comment claimed. A
percentage width on a grid item does not resolve against the track, so it
capped nothing. That is not a browser quirk: Chromium, Firefox and WebKit
all drew the same 260px.
`width: auto` in the phone rules instead. A grid item with an auto width
stretches to its grid area (justify-self defaults to stretch), so it fills
the track exactly with no percentage to resolve. The base rule keeps its
max-width, which is still right where this menu is not a grid item, and its
comment now says what it does rather than what it was hoped to do.
Measured, not guessed: decoding the failing test's screenshot pixel by pixel
shows the menu background #f7f7f7 running from x=3 to x=255 with the blue
board tiles painted on top of it from x=170, and the page background #dedede
only from x=260.
Also: when the Files report draws its headers and then "No results", the
test now says which half is missing. A row needs the page rows AND the
report_pages index that names them; the server count it already checks
proves only that the query matches. Rows but no index, index but no rows,
and neither are three different bugs that all show up as "element(s) not
found". That report is still failing - the server counts 5 and the pane
renders none, with every static link between them verified correct - so the
next run should name the layer instead of another read-through.
Thanks to xet7 !
Seven of the DnsBleed mocha tests failed with "Timeout of 2000ms exceeded" -
every one of the ALLOW cases, while every block case passed, which is what
hid it.
The stub was at fault, not fetchSafe. It faked http.IncomingMessage with a
bare EventEmitter that emitted 'data' and 'end' from a process.nextTick,
into the void if nothing was listening yet. No real response does that: an
IncomingMessage is a PAUSED Readable that buffers its body until a listener
attaches or it is resumed, so reading late cannot lose data.
That only started to matter when fetchSafe was split into resolving the
response and then reading it, which the redirect handling needs - it has to
see the status before deciding whether to read a body at all. The nextTick
queue drains BEFORE promise microtasks, so the fake had already fired 'end'
by the time the awaited continuation attached its listeners.
Verified rather than assumed, both halves:
- against a real server: a request whose 'data' listener is attached two
nextTicks, a setImmediate and 20ms after the response callback still
receives the whole body.
- against the real fetchSafe, driven by each stub in turn: the old
EventEmitter stub times out after 2s; the Readable-backed one returns
status=200, the body, the pinned IP 93.184.216.34 and Host
example.com.
The security assertions still hold through the new stub, checked the same
way: a 302 with the default maxRedirects:0 is refused, a host resolving to
127.0.0.1 is refused with ZERO requests sent, a followed redirect pins each
hop to its own resolved IP, the abandoned hop reports destroyed=true, and
Authorization and Cookie are dropped on the cross-origin hop.
destroy()/destroyed are now the stream's own rather than a hand-rolled flag,
so "fetchSafe abandoned this body" is asserted against real stream
behaviour.
Thanks to xet7 !
wekan/gitea, forked from go-gitea/gitea, is cloned to gitea/ here: a whole
Go + web application, 1,401 directories, 6,209 files, 384 MB. Nothing in
WeKan imports it.
Left out of .meteorignore it would have gone straight back into the build
scan - about a fifth again of everything Meteor legitimately walks in this
repository, and that scan is what ran the build out of memory. Left out of
.gitignore it would fill git status.
The guard added for exactly this caught it: with the entry missing from
.meteorignore, `every git repository cloned in here is excluded from BOTH
files` fails with
cloned repositories Meteor would still walk - add them to .meteorignore
actual: [ 'gitea' ]
It is named in the explicit list as well as found by the walk, because the
walk can only check a clone that is present - a checkout without gitea/ must
still not lose the entry.
Thanks to xet7 !
Excluding /_build/ and /_build-local-test/ in .meteorignore was wrong and
broke the build, with an error that does not mention .meteorignore at all:
error: Could not find mainModule for 'os' architecture:
_build/main-prod/server-meteor.js
Check the "meteor" section of your package.json file?
They look exactly like build output that should be ignored - they are
gitignored, and they are the first two entries of the 296 KB pattern list
that the heap snapshot turned up. They are not leftovers. rspack compiles
the app INTO _build/main-prod/ (and _build/test/ for a test run), and Meteor
then reads server-meteor.js and client-meteor.js from there as the
application's main modules: package.json names client/main.js and
server/main.js, and the rspack integration repoints mainModule at its own
output. Ignoring them hides the files Meteor is about to be handed.
They are 3 directories each, so there was nothing to win by excluding them
and a build to lose. The rest of the excludes - the Node.js fork checkout,
mongo-tools, TSC, the TSC website and the two variant WeKan checkouts - are
where the 5,860 directories were, and they stay.
The guard now asserts the OPPOSITE for these two, so the mistake cannot be
made again by anyone reading .gitignore and concluding the obvious thing.
The general "gitignored at the top level must be in .meteorignore" rule
gains them as documented exceptions: .gitignore happens to write them
unanchored today, so that rule skipped them by luck rather than by design,
and normalising .gitignore would have turned the guard itself into the thing
that breaks the build.
Thanks to xet7 !
The guard added with the .meteorignore fix derived its list FROM .gitignore,
so it only caught a clone that had got half way: one added to NEITHER file
was invisible to it. That is the state every one of these arrived in, so it
is the state worth failing on.
The check walks the tree looking for a directory with a .git of its own -
existsSync, because a submodule's .git is a file, not a directory - rather
than comparing against a list, so a clone nobody thought to name is caught
too. Anything it finds must be ignored by git (asked of `git check-ignore`,
not of a hand-parsed .gitignore) and, when it is a top-level directory,
listed in .meteorignore. It does not descend into a repository it has
already found: that repository's own submodules ride along with it.
A directory with a .git of its own is another project. It is not WeKan's
source and it is not WeKan's history, so it belongs in .gitignore to keep
`git status` readable and in .meteorignore to keep the build from walking
it.
Verified by planting a directory with a .git in it: the guard fails and
names it.
Thanks to xet7 !
`meteor build` ran out of JavaScript heap, and raising --max-old-space-size
never helped because the ceiling was not the variable.
A heap snapshot taken as the build died says what it was: of a 4.1 GB heap,
980 MB was 14,267,543 IgnoreRule objects, 667 MB sliced strings, 308 MB
concatenated strings and 274 MB in 945 copies of one 296 KB pattern list -
about 2.5 GB of ignore machinery, and only 945 directories in.
Meteor builds one ignore matcher per directory it descends into, each
carrying the whole accumulated pattern list. There were 6,867 directories
under the app; roughly 1,000 of them were WeKan. The rest were repositories
cloned in beside it - the Node.js fork checkout (4,132 directories, 45,527
files, 2.3 GB), mongo-tools, TSC, the TSC website, two more WeKan checkouts
- plus Meteor's own _build/ and _build-local-test/ output, which it was
scanning as source to produce. All of them were in .gitignore, which Meteor
does not read; it reads .meteorignore, which listed only .tools/, FerretDB/
and tests/. So the cost was very nearly seven times what the snapshot
caught.
The two variant checkouts were the worst of them: wekan-ondra and
wekan-gantt-gpl contain client/, server/ and models/, and Meteor loads
server/ and client/ eagerly - a second and third copy of the whole app
pulled into the build.
Excluding all of it takes the scan back to WeKan itself.
Also: build.sh exited 0 while printing "ERROR: the WeKan build failed".
build_wekan returns 1, the menu called it bare and threw that away, so
anything driving it non-interactively saw a green run and a missing bundle.
tests/meteorignoreScanScope.test.cjs pins the excludes, that they are
anchored to the repo root rather than matching a directory of that name at
any depth, and - for the next clone dropped in here - fails on any new
top-level directory that .gitignore excludes while .meteorignore does not,
which is exactly how each of these arrived.
Thanks to xet7 !
The capped snapshot worked - 6 GB written at a 4096 MB heap - and
releases/analyse-heapsnapshot.py (added here) says what is in it. Of 4,120 MB:
980 MB 14,267,543 x object IgnoreRule
667 MB 21,869,662 x sliced string
308 MB 10,105,340 x concatenated string
274 MB 945 x string "_build/test/** _build/main-dev/** node_modules/** .babelrc ..."
237 MB millions x string "t(?=$|\/$)", "s(?=$|\/$)", "e(?=$|\/$)" ...
Around 2.5 GB of the 4.1 GB heap is one thing: ignore-rule machinery. The
regexes are the `ignore` npm package's own rule compilation, IgnoreRule is its
rule class, and the 274 MB string is the pattern list it is compiling.
The arithmetic says what is happening. 274 MB across 945 copies is 296 KB per
copy - a pattern list of about 15,000 entries - and 14,267,543 rules across 945
constructions is 15,097 rules each. So a ~15,000-pattern ignore list is being
compiled from scratch 945 times, and the sliced and concatenated strings are the
paths it is built from. The repo has 3,030 tracked files and 104 top-level
entries, so 15,000 patterns is not "one per file": the list is accumulating
across those rebuilds.
That is the memory. It is not the minifier, not the heap ceiling, and not
anything in WeKan's own code - it is build tooling recompiling a growing ignore
list once per unit of work.
WHAT I COULD NOT DETERMINE HERE: which component does it. `ignore` is not a
WeKan dependency except through eslint, which does not run during a build, and
~/.meteor is not visible from this sandbox - so the caller is inside meteor-tool
or @meteorjs/rspack and needs a look from a shell that can read it:
grep -rl IgnoreRule ~/.meteor/packages/meteor-tool/*/mt-os.linux.*/dev_bundle/lib/node_modules/
releases/analyse-heapsnapshot.py is kept rather than left in /tmp, because this
will be wanted again. It streams, so it answers on a machine that cannot open
the file in DevTools, and it explains in its header why it reports self size
rather than retained size.
Two bugs in my own reporting, found by reading the output it produced:
* the failure said "allowed 15542 MB and reached 4280.0 MB" - it quoted the
COMPUTED limit while the snapshot mode had capped the build at 4096. It
quotes what the build was actually given now.
* it told you to run WEKAN_BUILD_HEAP_SNAPSHOT=1 even when that is what you
had just run. When a snapshot was requested it now points at the file and
the command that reads it.
Heap.*.heapsnapshot is gitignored: Node writes it into the repo root because
that is the build's working directory, and it is as large as the heap.
300 node suites pass, 0 failed.
The first heap-snapshot run was killed by the kernel - "Päätetty" - and left a
0-byte Heap.20260803.220733.2327163.0.001.heapsnapshot behind. The build log
ends mid-rspack with no GC dump at all, because the process never got to fail
its own way.
Writing a snapshot costs memory ON TOP of the heap being dumped. Asking a
15.5 GB heap to write a 15.5 GB snapshot, on a 30 GB machine with 12 GB already
in use, does not fit - the OOM killer arrives before the file does. So the flag
as written could not do the one thing it existed for.
WEKAN_BUILD_HEAP_SNAPSHOT=1 now caps the heap at 4096 MB rather than using the
computed limit. The build fails sooner, which is the point: a snapshot is not
more useful for being bigger. What is wanted is WHAT is retaining memory, and
whatever grows to 15 GB is already the largest thing on the heap at 4 GB - it
just gets there sooner and writes a file that fits in RAM and on disk.
WEKAN_BUILD_HEAP_SNAPSHOT_MB overrides it if 4096 turns out to be too early to
be representative.
The message says all of this, including that the build WILL fail and that a
full-size snapshot was already tried and killed, so nobody repeats it.
300 node suites pass, 0 failed.
The fourth run died the same way - 15526.3 MB of 15542 - and produced no more
information than the third, because the heap snapshot is opt-in and nothing told
anyone it existed. The flag was added in the last commit and the failure path
was not updated to mention it, which makes it a flag nobody will use.
The error now names the exact command:
WEKAN_BUILD_HEAP_SNAPSHOT=1 ./build.sh
and says what it produces, where, and how to read it.
It also says plainly what NOT to try, because the obvious move is wrong and has
now been wrong twice: the limit has already been raised once, 8192 to 15542, and
the build used the extra; removing the second JS minifier moved the peak by 6 MB.
Both of those are cheap to try again and neither will work, so the message says
so rather than leaving the next person to repeat them.
And the diagnosis is appended to the build log, not only printed. The point of
that log is that "check the newest test logs" answers the question - a diagnosis
that lives only in terminal scrollback is one the next reader does not have, and
the last four runs are exactly the case: the log ends on a V8 stack trace and
the explanation was somewhere above it on a terminal since closed.
Verified by rendering the message against the real log of the run that prompted
it: it reads back 15526.3 MB of 15542.
300 node suites pass, 0 failed.
The theory was that rspack minifies for production itself and standard-minifier-js
was running Meteor's minifier over that same output a second time, in exactly
the phase where `meteor build` runs out of memory. The run settles it:
with the minifier peak 15526.6 MB died after both rspack compiles
without it peak 15520.4 MB died after both rspack compiles
Six megabytes apart, which is noise, and it died in the same place. The minifier
was not the consumer, so it goes back. An unverified change to the release
artifact that demonstrably fixes nothing does not belong in the tree - and
because the build never completes, there was no way to confirm the resulting
bundle was still correctly minified either. Both .meteor/packages and
.meteor/versions are restored exactly as they were, byte for byte.
Three guesses is enough. build.sh takes WEKAN_BUILD_HEAP_SNAPSHOT=1 now, which
adds Node's --heapsnapshot-near-heap-limit=1 to the build so it writes a heap
snapshot just before it would die, rather than only dying. Off by default,
because the snapshot is about as large as the heap limit; when it is on the
build prints where the file lands and how to read it - Chrome DevTools, Memory,
Load, sorted by retained size.
That turns the next run into evidence instead of a fourth theory. Whatever is
retaining 15 GB in Meteor's bundle-writing phase will be at the top of that
list.
300 node suites pass, 0 failed.
Two builds died in the same place - after both rspack compiles report done, in
the phase where Meteor writes the production bundle - the second with twice the
heap of the first:
=> Compiled Rspack client app: done
=> Compiled Rspack server app: done
<--- Last few GCs ---> 8146 MB of 8192, then 15526 MB of 15542
So it was not a ceiling set too low. rspack is the bundler here and it minifies
for production itself - the asset-size warnings a build prints are about
rspack's own minified output, 4.797 MiB of client-rspack.js and its chunks.
standard-minifier-js then ran Meteor's minifier plugin over that same output a
second time, in exactly the phase that runs out of memory.
.meteor/packages says so in a comment rather than leaving the removal to look
like an accident, including what to check first if this turns out to be wrong: a
bundle that is broken rather than merely unminified twice means put it back.
The stale pin is dropped from .meteor/versions so the two files agree;
minifier-js stays, because it is a library other packages depend on rather than
the plugin that was running.
Four guards failed on this and on the last few commits, and all four were
pinning something that deliberately changed. Each keeps its assertion and gains
the reason:
changelogFormat - three Upcoming summaries of mine had no commit link,
which every summary in a release must have, and the
section was not grouped by area. That one was a real
defect in the CHANGELOG, not in the guard: the entries
are linked and grouped now, under "The extra-
architecture bundles", "Which Node.js a bundle carries"
and "Running the tests locally".
buildScriptParity - looked for `rm -rf .build` in the first 400 characters
of build_wekan. That window was an accident of how long
the comment above it happened to be, and it broke when
build_wekan gained an explanation of where its log
goes. It searches the whole function. It also needed
the five release-workflow helpers exempted from "every
script is a menu entry": they are called by a build job
with its matrix and environment, and would have nothing
to do on a developer's machine.
cpuExecWiring - looked for the qemu handling inline in release-all.yml.
It moved into releases/install-node-for-arch.sh, which
is the fix for the apostrophe-in-a-single-quoted-string
bug, so the guard looks there and also pins that the
workflow runs the file.
dbConformanceWiring - pinned ../log literally in three places. The root is
WEKAN_LOG_ROOT now, because ../log is not writable in a
Flatpak sandbox; what is pinned is that the same
question is asked and that WEKAN_LOGDIR still wins.
300 node suites pass, 0 failed.
NOT verified: the build itself - Meteor is not installable in this sandbox, so
whether dropping the minifier gets past the OOM is the next run's answer, and
whether the resulting bundle is correct is the thing to look at if it does.
The build failed again, and the log now says why on its own - but its last word
was a V8 stack trace, which reads as a crash rather than as the resource limit
it is. Running out of heap and failing to compile look identical at the level
build_wekan reports at, and they have nothing in common.
When the build log contains "JavaScript heap out of memory", the error now says
so, with the limit the build was given and the peak it actually reached, pulled
out of the last Mark-Compact line:
The build ran out of JavaScript heap. It was allowed 15542 MB and reached
15526.6 MB.
and points at the last line before the GC dump, which is the stage it got to.
It also says what NOT to do. Raising the number again is the obvious move and is
wrong once the figure is already large: this build used 15.5 GB, and the run
before it died the same way with half as much, so the ceiling is not what is
short. That belongs in the message rather than in somebody's memory.
Verified against the real log of the run that prompted it: the extraction finds
15526.6 MB from the final Mark-Compact line, and the sentence renders.
The build log added last commit earned its keep on the first run: the WeKan
stage failed again, and this time ../log/<datetime>/wekan-build.log says why,
on its last lines.
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory
at 8146 MB of an 8192 MB limit, on a machine with 30 GiB of RAM. The build had
not run out of memory. It had run out of the ceiling this script gave it -
--max-old-space-size=8192, hard-coded at the top with a comment saying to lower
it if your machine has less RAM, which is only half the problem: a constant is
simultaneously too small on a large machine and too large on a small one, and
nobody edits it until a build dies.
It is half of total RAM now, clamped to [4096, 16384]:
8 GiB -> 4096 (the floor - a small machine is not handed something unusable)
16 GiB -> 8192 (exactly the old constant, so nothing changes there)
30 GiB -> 15542 (this machine, nearly double what just failed)
64 GiB -> 16384 (the ceiling - past this, a heap that big means something is
wrong rather than something is big)
Half is the share that leaves the rest of the machine usable while a build runs.
Total RAM comes from /proc/meminfo, or hw.memsize on macOS, and falls back to
assuming 16 GiB if neither answers. The chosen size is printed at startup so the
log says what the build was given, and exporting TOOL_NODE_FLAGS or NODE_OPTIONS
yourself still wins.
Computed unconditionally rather than only when neither variable is set, which
was the first version of this and was wrong: with only ONE of them exported, the
other would have been handed a bare "--max-old-space-size=".
The dev server on :3000 had the same 8192 written into its command line and now
uses the same figure, so the two cannot drift.
Verified by running the block with /proc/meminfo stubbed at 8, 16, 30 and 64
GiB, giving the four values above; and with TOOL_NODE_FLAGS exported alone, which
keeps the exported value and still gives NODE_OPTIONS a real one.
Every log build.sh writes goes into a log/<datetime>/ directory, and the root of
those was hard-coded to ../log - one level up from the repo. That is the right
default and stays the default: a test run then does not appear in git status,
and the docs and the release process read it there.
But the parent of the repo is not always there to write into. A Flatpak sandbox
shares only the repository directory, so `..` is missing or read-only and
`mkdir -p ../log` fails - after which every redirection in the script either
failed outright or quietly dropped its log into the repo root. That is the worst
of both: the run looks like it logged something, and git status fills up with
stray files that are not part of the tree.
WEKAN_LOG_ROOT is resolved once at startup and used everywhere the path was
built by hand: ../log when the parent is writable, ./log inside the repo when it
is not, and whatever you set if you set it. The shape is log/<datetime>/ either
way, so nothing that reads these logs has to know which happened, and the chosen
path is printed when a run starts - including a line saying why, when it is the
fallback.
Everywhere means everywhere: the whole-run directory, one_log for the individual
test options, the Playwright per-browser runs, the EVERYTHING orchestrator, and
the wekan-log.log the whole session is teed into. releases/db-conformance.sh
makes the same choice when it is run on its own rather than under build.sh, and
the thirteen menu entries that told the reader "logs in ../log/<datetime>/" now
say "log/<datetime>/" - those strings are also the case labels the menu
dispatches on, so both copies had to move together.
/log/ is gitignored, for the case it lands inside the repo.
Verified both branches by running the resolver in a temporary tree: with a
writable parent it chooses ../log and creates it; with the parent chmod'ed
read-only it prints the note, chooses ./log inside the repo, creates that, and
does NOT create ../log. build.sh, db-conformance.sh and run-everything.sh all
pass bash -n.
The newest test run ended
FAIL WeKan tests (sequential)
PASS Database conformance (all databases for this CPU)
PASS FerretDB tests (unit, vet, integration)
and ../log/2026-08-03_19-32-26/ held twelve conformance and FerretDB logs and
NOTHING about WeKan. Not one line about why WeKan never got as far as running a
test. The build writes to the terminal only, so the one question that run raised
was the one question its logs could not answer - and "check the newest test
logs" is how these runs are meant to be read.
What can be said from the tree it left: node_modules is 654 MB, so
`meteor npm install` finished; .meteor/local is 43 MB where a completed build is
gigabytes, and .build does not exist at all, so `meteor build .build --directory`
started and died partway. Why, nothing recorded. Meteor is not installable in
the sandbox this is being fixed from, so it cannot be reproduced here either.
build_wekan tees into wekan-build.log in the run's own log directory now, and
prints the path before it starts. An interactive build still scrolls past
exactly as it did.
Two more things found while in there.
build_wekan did not check whether the build worked. `meteor build` failing left
the function returning success, and the caller learned about it only afterwards
by noticing .build/bundle was missing - which is how this failure was reported,
one level away from the thing that failed. It checks the exit status and the
bundle, and says which log to read.
run_all_tests minted its own ../log/<datetime>/ even when a larger run had
already set WEKAN_LOGDIR. That is exactly the split-across-two-directories the
per-run directory exists to prevent; it landed in the same second as the outer
one this time, so nothing showed, and on a slower machine half a run's logs
would have gone somewhere else. It uses the outer directory when there is one
and exports its own when there is not, so every job of a run agrees on it
instead of each calling date() again.
Verified: build.sh passes bash -n, and the tee construct was exercised on its
own - a failing command inside the group produces exit 1 from the function, the
ERROR path, and a complete log file, rather than tee's own zero.
Two things a WeKan release did not say, and now does.
WHAT IT SHIPS. Each bundle zip now has a wekan-<version>-<arch>.zip.sha256sum
beside it on the release, so a download can be checked. The bundles were the one
thing in this chain with no checksum at all - the binaries inside them are
verified where their sources publish one, and the zip carrying them was not.
WHERE ITS BINARIES CAME FROM. A bundle is assembled out of a Node.js, a
FerretDB and the MongoDB tools, and WHICH source serves each varies per release
and per CPU: nodejs.org builds some architectures, unofficial-builds others, the
wekan/node fork the ones neither of them does, and not all of them publish a
checksum. None of that was visible anywhere except a build log that expires, so
"where did the node in the arm64 bundle come from, and was it checked" had no
answer.
Each build job records a row per binary - bundle, binary, source, version,
whether it was checked, the SHA256 - and the release notes open with the table.
A row saying "no checksum published" is not a failed check but a source that
publishes nothing to check against, and the table says so, because those are the
ones worth fixing at the source rather than mistaking for failures.
The table is written twice, and that is deliberate. The release is CREATED
before win64, mac-arm64 and the extra architectures are built - those jobs need
a release to upload their bundles to - so the table it is created with can only
cover amd64 and arm64. A new release-notes job runs after all of them and
rewrites the notes with every bundle in. It is always(), so a platform that
failed does not cost the release the table for the platforms that succeeded.
FerretDB is now verified too, where it can be: wekan/FerretDB publishes a
.sha256sum beside each binary as of the commit in that repository alongside
this one, and the extra-arches job checks the download against it. An older
release without one is recorded as unverified rather than failing the build -
the binary IS available, which is the test that matters.
Nothing new fails a build. A missing checksum is a warning, a missing MongoDB
tool is a warning; only a binary that does not exist at all stops the run, which
is what it did before.
Verified: every script parses under bash -n; release-all.yml is valid YAML with
its nineteen jobs and the new one wired to all five build jobs; and the table
renders correctly from real rows - three of them, two sources, one with a
checksum and one without - producing the markdown checked by eye.
Replaces the fork-first entry, which this supersedes: the order is official,
then unofficial, then the fork, by how verifiable each build is - and the
checksum is now actually checked, with a retry before it is fatal.
Thanks to xet7 !
The three sources do not offer the same assurances, and this was checked rather
than assumed:
nodejs.org SHASUMS256.txt, and SHASUMS256.txt.sig / .asc signed with
the Node.js release keys
unofficial-builds SHASUMS256.txt, no signature
wekan/node fork neither, until the commit in that repo alongside this one
So the order goes back to official, then unofficial, then the fork - descending
verifiability, with the fork as the backstop for what the other two do not
build. A binary somebody else signed is worth more than one this project built,
and the fork's own fixes are build-configuration fixes upstream's builds do not
need: they are what makes the CPUs upstream does NOT build compile at all.
Preferring a source because it publishes a checksum and then not checking it
would be preferring it for nothing, so the checksum is now looked up in the
preflight - from the source's own SHASUMS256.txt, or from the fork's per-binary
.sha256sum - and the download step refuses a file that does not match it.
A mismatch is retried before it is fatal. The overwhelmingly likely cause is a
truncated or corrupted transfer, a CDN edge that served a partial file or a
connection cut mid-stream, and that is fixed by asking again. What retrying must
not do is give up quietly: after three attempts the build stops, because at that
point the file being served is not the file that was published.
Where no checksum exists the log says so in as many words, rather than leaving
the reader to assume a check was made. FerretDB and the MongoDB tools are in
that position today - neither wekan/FerretDB nor wekan/mongo-tools publishes
checksums for its release binaries, which is the next gap to close.
Verified by exercising download_and_verify directly: a matching checksum
returns 0, a missing one returns 0 with the file named as unverified, and a
mismatching one retries three times and exits 1. And by resolving the real
checksums for every architecture against the live releases - s390x and ppc64le
from nodejs.org, riscv64 and loong64 from unofficial-builds - then downloading
the riscv64 tarball and confirming its hash matches the published one.
The fork is not "the CPUs nobody else builds" any more - it builds all thirteen
platforms WeKan ships a bundle for, and it carries fixes upstream does not: the
ICU genccode architecture name, the x86 /SAFESEH opt-out, the zlib SSE2 and NEON
flags, the V8 template disambiguator. Those are build-configuration fixes that
any platform benefits from, and a set of bundles should not be half built on one
Node.js and half on another depending on which CPU it is for.
So the probe order is fork, then nodejs.org, then unofficial-builds - the last
two now being fallbacks for the case the fork has not published a version yet,
rather than the other way round.
The rest of the rule is unchanged and still matters: the newest Node.js that
exists FOR THIS CPU, walking versions from newest down and asking all three
sources at each one. Preferring the fork does not mean pinning to whatever the
fork last built.
Checked against the live releases, which show the transition in progress - the
fork has v24.18.1 for the architectures it already built and has not yet run for
v24.19.0:
i386 v24.18.1 fork
loong64 v24.18.1 fork was coming from unofficial-builds before this
s390x v24.19.0 official fork has not built v24.19.0 yet
ppc64le v24.19.0 official likewise
riscv64 v24.18.1 unofficial likewise
armhf - none still nothing anywhere
Once node.yml has run for v24.19.0 with its new matrix, every one of these
resolves to the fork; until then the fallback chain keeps the bundles building
instead of blocking on it.
Splits the version-fallback out of the binary-check entry, because it is its own
change and its own reason: the fallback now applies to all three sources rather
than only the fork, which is what makes riscv64 build again.
Thanks to xet7 !
The version fallback added with the preflight check was only wired to the
wekan/node fork. nodejs.org and unofficial-builds were still pinned to the exact
newest release, so a CPU that HAD a perfectly good build, one patch version
back, was reported as having none at all.
riscv64 was exactly that. unofficial-builds publishes riscv64 up to v24.18.1 and
has not got to v24.19.0; the check looked only at v24.19.0, found 404s, and
stopped the job saying "no Node.js for riscv64 exists anywhere" - while
node-v24.18.1-linux-riscv64.tar.xz was sitting there.
The rule is now "the newest Node.js that exists for this CPU". It walks the 24.x
releases from newest down and asks all three sources at each one - official,
then unofficial, then the fork - and the first hit wins, which is by
construction the newest build that exists anywhere for that architecture. Those
are the same thing on amd64 and arm64 and regularly are not anywhere else:
nodejs.org builds a handful of architectures, unofficial-builds adds a few, the
fork builds the rest, each on its own schedule, so the further a CPU is off the
beaten path the further behind its newest build tends to be.
When the answer is behind the newest, the log says which version it got, from
where, which version it wanted, and what to build to bring it in line. It is a
warning, not an error: the alternative is no bundle at all for that CPU.
The walk stops after 12 releases. A CPU whose newest build is a dozen releases
old is not slightly behind, it is unmaintained, and saying so is more use than
quietly shipping something from last year.
Re-ran the check against the live releases for all six architectures:
s390x v24.19.0 official newest
ppc64le v24.19.0 official newest
riscv64 v24.18.1 unofficial was failing before this change
i386 v24.18.1 fork
loong64 v24.18.1 unofficial the fork has it too; unofficial is tried first
armhf - none exit 1, naming node-armhf
and checked armhf by hand across six releases back to v24.10.0 at all three
sources, to be sure "nothing anywhere" is the truth about armhf and not a bug in
the walk. It is: every one is a 404.
Four of the six now come out newer or working where they did not before, and the
one that still fails fails for a real reason.
The six extra-architecture jobs that all failed in the v10.57 run, grouped under
the area they belong to: the shell quoting bug that emptied the CPU name out of
the Node.js URL, the base image that is not built for i386 or loong64, and the
checks that now run before any of it and name the missing file.
Thanks to xet7 !
Every one of the six extra-architecture jobs failed in the v10.57 run, and none
of the six failures was about WeKan.
FOUR OF THEM - ppc64le, riscv64, s390x, armhf - died on the same 404, at a URL
with no architecture in it at all:
curl: (22) The requested URL returned error: 404
No Node.js for at official (https://nodejs.org/dist/v24.18.1/node-v24.18.1-linux-.tar.xz)
The container script was passed as `bash -c '...'` and contained apostrophes, in
comments like "# Where this CPU\'s Node.js comes from". A single-quoted shell
string cannot hold an apostrophe - \' does not escape it, it ENDS the string -
so the script was chopped into words at the first one, and ${NODE_ARCH} landed
in a region the RUNNER's shell expanded rather than the container's. The runner
has no NODE_ARCH, so it expanded to nothing. Reproduced here before changing
anything: that construct really does deliver only the fragment up to the
apostrophe and leave the rest as separate arguments.
The script is a file now, releases/install-node-for-arch.sh, mounted into the
container. A file has no quoting layer to get wrong, and `bash -n` can check it.
THE OTHER TWO - i386 and loong64 - died earlier still, on
docker: no matching manifest for linux/386 in the manifest list entries
ubuntu:26.04 publishes amd64, arm/v7, arm64, ppc64le, riscv64 and s390x. Not
386. Not loong64. The base image is named per architecture in the matrix now and
is debian:trixie, which publishes 386 as well - checked against the registry
rather than assumed.
loong64 still cannot be built, and that is now said rather than discovered: no
image on Docker Hub publishes linux/loong64 at any tag, so there is no loong64
userland to rebuild the native modules in. node-loong64 and ferretdb-loong64
both exist; the container does not. It stays in the matrix so it stays visible.
AND UNDERNEATH BOTH: nothing checked that the binaries these bundles are
assembled from had been published at all. A bundle takes FerretDB from
wekan/FerretDB, the MongoDB tools from wekan/mongo-tools, and on the CPUs nobody
else builds for, Node.js from wekan/node - and any of those can be absent
because a build has not finished. releases/check-arch-binaries.sh now runs
before anything is built and checks the base image, the Node.js, FerretDB and
the tools; releases/require-binaries.sh does the same for the amd64, arm64,
win64 and mac-arm64 bundles. Each missing file gets a line naming it and the
repository that should publish it, and the job stops there. The MongoDB tools
stay a warning: FerretDB is the database and the launcher does not need them.
It also handles wekan/node running behind. The fork builds the architectures
nobody else does, one release at a time, so the newest Node.js is regularly a
version it has not reached - today nodejs.org is at v24.19.0 and the fork at
v24.18.1. Asking for the exact newest version would fail for exactly the CPUs
the fork exists to serve, so the check falls back to the fork's newest release
that HAS that CPU and says out loud that the bundle is a patch version behind.
Verified by running the check against the real releases, for all six:
s390x Node.js v24.19.0, official exit 0
ppc64le Node.js v24.19.0, official exit 0
i386 Node.js v24.18.1, wekan/node exit 0
loong64 Node.js v24.18.1, wekan/node exit 0
armhf nothing anywhere exit 1, naming node-armhf
riscv64 nothing anywhere exit 1, naming node-riscv64
armhf and riscv64 are the two the fork has not finished; the log now says which
file to publish instead of a bare 404. i386 and loong64 resolve because
node-i386 and node-loong64 were added to the fork's v24.18.1 release.
NOT verified: the builds themselves, which need the runners. What was checked:
that release-all.yml is still valid YAML and every job has its check step; that
the three new scripts pass `bash -n`; that the quoting bug reproduces and the
file form does not; that debian:trixie really publishes linux/386 and
ubuntu:26.04 really does not, from the Docker registry; and the six preflight
results above, run against the live releases.
Two more ways to install WeKan, and neither of them rebuilds it.
release-all.yml already produces a bundle that runs offline with nothing
installed: the Meteor server, the bundled Node.js the release was built
against, the wekan/FerretDB v1 fork with its embedded pure-Go SQLite backend,
the wekan/mongo-tools binaries, the cpu-exec/qemu-user shim from #6458 and
start-wekan.sh to bring them up. That bundle is already relocatable and already
self-contained - it is the offline zip people download today. So both workflows
take wekan-<version>-<arch>.zip off the release and wrap it, which means the
AppImage and the Flatpak are byte-for-byte the same server, Node and FerretDB
as the zip beside them, rather than second and third builds that could drift
from it and from each other.
For the person installing, both mean the same thing: one file, no MongoDB to
install, no Node to install, no database server to run. FerretDB v1 keeps
everything in SQLite files.
AppImage.yml wraps the bundle in an AppDir with WeKan's own icon and an AppRun
that hands over to start-wekan.sh, so every variable that launcher documents -
WRITABLE_PATH, PORT, ROOT_URL, MONGO_URL, WEKAN_DB - still works. It defaults
WRITABLE_PATH to ~/.local/share/wekan, because an AppImage is read-only and may
be sitting on a USB stick, so the data cannot live beside it. After packing it
STARTS the AppImage and waits for WeKan to answer on its port before uploading
anything: an AppImage that packs is not an AppImage that runs, and a broken
bundle should be caught here rather than by whoever downloads it.
Flatpak.yml uses `flatpak build` rather than flatpak-builder, because the
bundle is the build and there is nothing to compile. Its sandbox gets
--share=network and --persist=.wekan and nothing else: WeKan is a server
reached in a browser, so it needs the network and a place for its data, not
X11, audio or the user's files. It produces both a single-file bundle per
architecture and a multi-architecture repository - the bundle is what you
install by hand, the repository is what makes `flatpak update` work - and the
repository is static files, so a website can serve it with no server to run.
The Flatpak repository is unsigned. Signing needs a GPG key, which is a secret
rather than something a workflow can invent, so the README written into the
repository sets out the four steps to sign it when a key exists: make the key,
put the private half in GPG_PRIVATE_KEY, add --gpg-sign to the export and the
update-repo, and publish the public half beside the .flatpakrepo.
Both cover x86_64 and aarch64, which is where the two constraints meet: AppImage
and the freedesktop runtime have those architectures, and WeKan publishes
amd64 and arm64 Linux bundles. Each builds on a runner of its own CPU, because
appimagetool packs for the architecture it runs on. If a bundle for another
architecture appears on a release, adding a matrix line wraps it the same way.
NOT verified: neither workflow has been run - that needs the runners. What was
checked: that both are valid YAML and their matrices resolve; that the icon
they use exists in public/; that the four files they assert on
(bundle/main.js, bundle/node, bundle/ferretdb, bundle/start-wekan.sh) are what
release-all.yml actually puts in the bundle; and that the newest release really
does carry wekan-10.57-amd64.zip and wekan-10.57-arm64.zip under those names.
CLAUDE.md sets the shape of a `<details>` block precisely - `<details>` and
`</details>` each on their own line, the whole `<summary>` on ONE line, a blank
line under it, the body, a blank line, the close - and three blocks were missing
the blank line before `</details>`: the Version block in `# Platforms`, the
`Carried to a future release.` block at the top of `# TODO Later`, and one entry
in v10.55.
They render either way, which is why they survived; the rule is there so every
block looks the same in the file, and a block that does not is the one somebody
copies next.
Also rewrapped the one line in the `# TODO Later` intro that had grown to 152
characters, against the 80 the file is wrapped at. It carries no link, so
nothing stopped it being wrapped.
Thanks to xet7 !
The TSC website (Secretchronicles/secretchronicles.github.io), cloned inside
this repo to be worked on beside the game itself. Like the others it is a whole
git repository of its own, so git would not commit it into this one, but it
showed up as untracked noise in every status.
Thanks to xet7 !
git@github.com:Secretchronicles/TSC, cloned inside this repo to be worked on.
It is a whole git repository of its own, so git would not commit it into this
one, but it showed up as untracked noise in every status.
Listed beside the other local-only working copies - node, FerretDB, sandstorm,
mongo-tools, mongosh and the two wekan variants - with the same one-line note
saying what it is a clone of.
Thanks to xet7 !
Two reported bypasses of the SSRF guard, so the section now leads with the
CRITICAL SECURITY ISSUES header and the documentation reorganisation becomes
"and reorganises the documentation:". Both entries are grouped under "The SSRF
guard - what it checks, and what it was deciding from", because that is the one
thing both reports found from two directions.
The "In short" paragraph is rewritten around them: what each bypass was, in one
clause each, then the documentation work accounted for in the closing sentence.
Also records the Sandstorm bridge guard reading the page at its new path, which
the docs reorganisation left pointing at a directory.
Thanks to RandomGenerator, tonghuaroot and xet7 !
Two separately reported bypasses of the same defence, fixed together because
they are the same mistake in the two halves of it: the guard decided from
something it did not control.
FollowBleed (GHSA-j9p2-jm73-p549, reported by RandomGenerator against v10.53) -
the live Trello import validated the attachment URL with validateAttachmentUrl(),
which is the fix that closed LiveBleed / CVE-2026-30844, and then downloaded it
with the platform fetch(), which FOLLOWS REDIRECTS. So the guard only ever saw
the request, and the target gets to answer:
1. the attacker puts http://<public-host>/attachment.txt on a Trello card
2. validateAttachmentUrl() resolves it, sees a public IP, allows it
3. that host answers 302 Location: http://127.0.0.1:18080/secret
4. fetch() follows, and the loopback body is stored as the imported
attachment, readable back through WeKan
Non-blind SSRF against loopback services, internal admin panels, cloud metadata
and anything else reachable from the container - the exact thing the validation
was added to stop, reached through the response instead of the request.
A guard on the URL alone cannot hold, so fetchSafe() now guards EVERY HOP.
`maxRedirects` (default 0) is how many redirects a caller is willing to follow:
0 keeps the old behaviour of refusing any 3xx outright, which is right for
outgoing webhooks and avatar downloads because a legitimate one never redirects.
A caller that must follow one passes a small number, and each hop goes through
the same protocol allowlist, blocked-range check and DNS pinning as the original
URL before a packet is sent to it. Credentials are dropped on a cross-origin
redirect, so following Trello's 302 to S3 cannot hand the API key and token to
whoever the redirect names.
All three live-import sinks - downloadAttachmentBase64, inlineBoardBackground,
inlineMemberAvatars - download through it. Refusing every redirect was not an
option: Trello's own attachment endpoint answers with a 302 to a signed S3 URL,
so that would have meant importing no attachments at all.
The offline importers had the same hole and were not in the report. They handed
the validated URL to Attachments.loadAsync(), and Meteor-Files downloads with
the platform fetch() too, so a pasted Trello or WeKan board export reached
127.0.0.1 by exactly the same 302. models/lib/importAttachmentDownload.js
validates, downloads through fetchSafe with every hop checked, and hands back
the bytes, which are stored with the writeAsync() both importers already use for
an attachment that arrived inline.
TransitBleed (GHSA-c5xr-mg26-vq5w, reported by tonghuaroot) - isIpBlocked() in
models/lib/attachmentUrlValidation.js is the one block-list behind BOTH the
input-time validator and the delivery-time guard, and its IPv6 half classified
an address by its spelling: startsWith('::ffff:'), startsWith('2001:db8'), and
the first hextet parsed out of the string. IPv6 has several standard ways to
write "this packet goes to an IPv4 address" and none of them looks like
`::ffff:`:
2002:a9fe:a9fe:: 6to4 (RFC 3056) -> 169.254.169.254
64:ff9b::c0a8:101 NAT64 (RFC 6052) -> 192.168.1.1
2001:0:... Teredo (RFC 4380) -> complement of the low 32 bits
0:0:0:0:0:ffff:7f00:1 IPv4-mapped, spelled out -> 127.0.0.1
On a host with a 6to4 relay or a NAT64 gateway, which is ordinary in cloud and
Kubernetes networks, the packet arrives at that IPv4 address. So
http://[2002:a9fe:a9fe::]/latest/meta-data/ read cloud metadata straight through
the guard whose whole job was to stop it, and even a plain IPv4-mapped address
written out in full missed the check.
The address is EXPANDED to its 16 bytes once and every check reads those bytes,
so notation cannot change the answer, and every transition form has its embedded
IPv4 extracted and re-checked with the IPv4 rules - 6to4, NAT64 (well-known and
RFC 8215 local-use prefixes), Teredo through both its server and its obfuscated
client address, IPv4-mapped, IPv4-translated, IPv4-compatible and ISATAP under
any routing prefix, not only the link-local one. The deprecated fec0::/10
site-local range is blocked too. A transition address wrapping a PUBLIC IPv4 is
still allowed, because a guard that blocks everything gets switched off.
Tests. tests/followbleed.test.cjs replays the reported attack against a stubbed
transport - a public host that 302s to 127.0.0.1 - and asserts the second hop is
never sent, then covers redirects to a hostname resolving to a private IP, to
metadata, to a non-http scheme, relative Location, the chain limit, credential
stripping across origins, 303/307 method handling, and that a legitimate
public-to-public redirect is still followed with each hop pinned. It also pins
the sinks: the only bare fetch() left in the live import is the hardcoded
api.trello.com call, and neither offline importer uses loadAsync any more.
tests/transitbleed.test.cjs checks every transition form in both directions.
Both are in the plain-node suite; the guard's own dnsbleed suite gained the same
cases. 300 node suites pass.
Thanks to RandomGenerator and tonghuaroot for the coordinated disclosures, and
xet7 !
The docs reorganisation (f628a698) turned
`docs/Platforms/FOSS/Container/Sandstorm` from a page into a directory, and
this test reads that page at run time to pin what it documents. So it did not
merely go stale - `fs.readFileSync` on a directory throws EISDIR and the suite
died before its assertions ran.
It reads the directory's landing page, `README.md`, which is where the
migration-bridge documentation ended up. Both assertions are unchanged and both
pass.
Thanks to xet7 !
Two entries for the two commits since the last changelog update, and the bug
subsection is grouped by area now that it holds three: the snap, and All Boards.
Grouping meant rewording two summaries - an entry under a group must not repeat
the group's own name, and both began with it.
The In short paragraph follows: it named two of the three fixes and now names
all of them.
The counts entry goes under All Boards with the left-menu one; the remote-build
diagnostics are release tooling rather than a bug in WeKan itself, so they get
their own subsection after the fixes, as CLAUDE.md orders them.
Thanks to xet7 !
Starred, Home, Templates and Archive have labels of four different lengths, and
on a phone the count chip sat immediately after its label - so the four numbers
landed at four different x positions and the column of counts read as ragged
rather than as a column at all.
That packing was deliberate (#6523): the number beside the text rather than
across a gap, on the grounds that a landscape phone's menu is wide enough for
the gap to look like a mistake. Reversed here, because the alignment is what a
reader is actually using - the counts are compared with each other down the
column, and four x positions is what stops that.
The row keeps the space-between and the flex: 1 label the base rule gives it, at
every width, so every count sits at the end of its own row and they line up. The
phone override is gone rather than re-tuned; there is no width at which the
ragged version was wanted.
Guarded both ways in mobileAllBoardsFit.test.cjs: the row must spread its two
children apart and the label must take the slack, AND no narrower width may take
either back - a phone override is exactly how they came to be ragged.
Thanks to xet7 !
The v10.55 riscv64 leg failed three times in sixteen minutes, and the whole of
what it printed was:
Git operation failed with: Could not push 'HEAD' to
git.launchpad.net/.../+git/snapcraft-wekan-<hash>
with refspec 'HEAD:refs/heads/main'
This is a different failure from the other two arches. It is not the staging bug
- that happens minutes into a build, on the builder. Here the build never
started: remote-build uploads the source to a Launchpad git repository first,
and the upload is what failed. ppc64el and s390x pushed fine in their own runs,
with the same credentials.
Why it is still unconfirmed: snapcraft swallows git's own error, writes it to
its execution log, names that log's path in the output, and nobody reads it. So
"rejected", "timeout", "auth" and "too big" all look identical from the job log
- which is why three runs have not narrowed it down.
Two changes, both about being able to tell next time:
The failed-attempt handler prints snapcraft's own execution log, not only the
Launchpad build log. The Launchpad one does not exist when the build never
started, which is exactly this case.
And the retry clears ~/.cache/snapcraft/remote-build before waiting. snapcraft
keeps a local clone of the Launchpad repository there, and a retry that reuses a
half-pushed one repeats the same failure - three identical attempts sixteen
minutes apart is what that looks like.
The leading suspect is recorded in ../log/snap/TODO.txt rather than guessed at
here: remote-build pushes HEAD:refs/heads/main with no force, WeKan's history
was rewritten between v10.54 and v10.55 by the release rebase, and a `main` left
behind by the previous run is then no ancestor of today's HEAD. If the log the
job now prints says "rejected", deleting that scratch repository is the fix, and
the longer-term answer is for the workflow to delete it or to use a fresh one
per run.
Thanks to xet7 !
The three commits since v10.55, each linking the commit it describes: the
platform expansion as a new feature, and the snap that could not finish staging
on any FerretDB-only architecture and the left menu lying over the boards on a
phone as bug fixes.
One style fix with it: the dependency bullets in v10.55 wrote their link text as
[update] where v10.54 and everything before it write [Update].
The hashes were checked rather than assumed - every commit link in the new
section resolves AND is an ancestor of HEAD, which is the property that matters
after a rebase: a hash that still exists locally but has left the branch is a
404 the moment it is pushed. The rest of the file was checked the same way and
needed nothing.
Left alone: hash LENGTH. The two newest releases happen to use 9-character
prefixes, which is what a sample of them says, but the file as a whole is 2811
forty-character hashes against 399 short ones - so the newest entries are the
outliers, not the rule, and normalising the file to match a sample of itself
would have rewritten every release ever published.
Thanks to xet7 !
Every non-amd64, non-arm64 platform already used FerretDB v1 - MongoDB ships no
server for any of them - but only three of them were built, because only three
had a Node.js runtime anyone published. wekan/node now builds the rest, so the
gap is gone and the release follows FerretDB's list.
New bundles: i386, armhf and loong64, beside the ppc64le, s390x and riscv64
already there. They go into the snap (i386 and armhf; loong64 is not a snap
architecture) and into the multi-arch image (linux/386 and linux/arm/v7;
buildx and the three registries do not agree on loong64 yet).
WHERE THE NODE.JS COMES FROM is now resolved at build time rather than declared:
nodejs.org, then unofficial-builds, then the wekan/node fork, in that order, and
the log says which one served. The first two ship a tarball; the fork ships the
bare binary it built, because that is the only part missing - so when the fork
serves, npm comes from the official amd64 tarball of the same version. npm is
JavaScript and runs on whatever node executes it, so an npm built for one CPU
drives a node built for another.
THREE VOCABULARIES meet in this matrix and they disagree: Node says x86 and
armv7l where Debian, snap and FerretDB say i386 and armhf, and snap says ppc64el
where everyone else says ppc64le. Each row now names all three - arch, node_arch
and ferretdb_arch - because a row that named one of them would download another
CPU's binary and nothing would notice until somebody ran it.
The MongoDB Database Tools are per-tool tolerant now: wekan/mongo-tools does not
publish every arch, and a missing mongodump is a missing convenience rather than
a broken bundle - FerretDB is the database. It removes the inherited amd64 one
instead of shipping it, because a tool for the wrong CPU is worse than no tool.
armel is the one FerretDB target left out, and deliberately: V8 has not
supported ARMv5 for many years, so there is no runtime to put in the bundle and
the fork cannot build one either.
A new guard reads the four lists that have to agree - the bundle matrix, the
snap platforms block and its arch mapping, the snap-launchpad matrix and the
Docker platform list - and checks every snap arch has a bundle behind it, every
bundle row names all three vocabularies, and buildx builds exactly the platforms
whose push is then verified. Two older guards pinned the five-platform snap list
and now pin the seven.
Written up in docs/Design/Autoupdate/Platforms.md.
Not verified by a real release run: no workflow here can be executed, and the
first run is what will say whether the loongarch64 cross toolchain is available
on the runner and whether each platform's Node.js resolves where expected.
Thanks to xet7 !
The s390x snap died in the STAGE step of both its Launchpad builds, right after
"Staging mongodb":
/build/snapcraft-wekan-<id>/stage/bin: Is a directory
IsADirectoryError: filename: '/build/.../stage/bin'
The mongodb part's stage-packages unpack an Ubuntu 24.04 merged-/usr layout,
which puts everything under usr/ and leaves `bin` in the part as a SYMLINK to
usr/bin. On amd64 and arm64 the part's build then downloads MongoDB and copies
its binaries into $SNAPCRAFT_PART_INSTALL/bin, which replaces that symlink with
a real directory. On s390x, ppc64el and riscv64 MongoDB ships no server, so the
build exits early - "No MongoDB server for ...; FerretDB-only arch" - and the
symlink survives.
mongodb is staged LAST of the parts that carry a bin/, so stage/bin is already a
real directory by then, from wekan, caddy and helpers. Copying a symlink onto an
existing directory is the IsADirectoryError, and it fails the whole snap rather
than that one part.
An override-stage turns a `bin` SYMLINK into an empty real directory before
staging, then stages normally. It tests for a symlink specifically, so the
arches where mongod really is there are untouched; it removes before mkdir,
because mkdir -p FOLLOWS a symlink and would change nothing; and an empty real
directory merges into stage/bin without changing anything else.
ppc64el is the same FerretDB-only branch and would hit this too - its own logs
never got that far, because snapcraft's monitor loop died fetching the
in-progress build log from launchpad.net with "[Errno 101] Network is
unreachable" while the build itself was still compiling, and each retry then
submitted a NEW build instead of recovering the running one. That half is
infrastructure, not this file; it is written up with the rest in
../log/snap/TODO.txt.
riscv64 takes the same branch, so this covers it as well.
Not verified against a real Launchpad builder: the fix is read off the failure,
and the s390x leg has to be re-run to confirm it now reaches "Snapped".
Thanks to xet7 !
Spec 43 (All Boards on a phone) still failed in all three browsers with the same
numbers as before: the menu 260px wide over a 157px column, lying across the
boards.
max-width: 100% was not enough, and that is the part worth knowing: a grid
item's default min-width is `auto`, which is its content's intrinsic minimum,
and a MINIMUM BEATS A MAXIMUM. The cap did nothing until the item was allowed to
shrink to it. `min-width: 0` is the same pair the right-hand column beside it
has carried all along, for the same reason and with the same comment. Nothing
inside the menu needs the intrinsic width held open - the workspace name already
ellipses through its own min-width: 0.
Two guards followed the format rather than the other way round. Both were
written against a release with forty entries and rejected v10.55 for being four
dependabot bullets and nothing else: CLAUDE.md keeps a dependency batch as plain
bullets, so a release can legitimately have no <details> at all, and padding one
bump into a block whose body repeats its summary would be the noise that rule
exists to prevent. They count entries OR bullets now.
The empty Files report (spec 41) is NOT fixed - it is made legible. The helper
that fills that table caught every exception and returned [] in silence, which
renders exactly like "there is nothing to show": headers, "No results", and
nothing anywhere recording that it threw. It logs now. The publication says one
line when it matches nothing, whatever DEBUG is set to, because a publication
answering with nothing and a publication never running look identical from the
outside - which is why three runs have not told us which this is.
And node/ - a 2.2G clone of the Node.js 24.x fork the runtime is built from -
joins the other local-only clones in .gitignore, rooted like they are.
Thanks to xet7 !
Four dependabot updates have landed since the release, and none of them is in
the shipped bundle: the two halves of typescript-eslint, sinon, and the
docker/login-action step that signs in to the container registries when a
release image is published. They are a plain bullet each, with the package, the
version pair, one line on what it is, its pull request and the commit that
brought it in - the shape CLAUDE.md prescribes for a dependency batch.
`abdcf8134` needs no entry of its own: it reworded a summary inside the v10.54
section, which is already released.
Two guards had to follow. Both were written against a release with forty entries
and failed this one for being small:
* "the Upcoming section has at least one ENTRY" - a release whose whole
content is a dependency batch has none, because a batch is bullets, and
padding a bump into a <details> whose body repeats its summary would be
noise added to satisfy a test. It asks for at least one change now, entry or
bullet.
* "at least ten entries are grouped" - a fixed count fails a small release for
being small. It is a ratio now: most of what is there is grouped, which is
what the rule is about, and the loose-entry limit beside it is the other
half of the same check. Verified it still catches six ungrouped entries.
Thanks to xet7 !
A workspace held boards and, in the data, other workspaces - but a drag could
only ever say one thing about them, and the fold was not there at all.
WHERE IN A ROW it is dropped is now what the drop MEANS:
the top quarter - it becomes that row's previous sibling
the bottom quarter - it becomes that row's next sibling
the middle half - it becomes that row's LAST CHILD, a sub-workspace
The middle is the biggest target on purpose: reordering can also be reached by
aiming at the neighbouring row's far edge, but nesting has only this one. Coming
back up is a drop like any other - a child dragged onto a root row's edge is a
root again - because nesting has to be undoable or a workspace put one level too
deep is stuck there.
THE PLACEHOLDER IS A SLOT, NOT A LINE. While the pointer is over a row an empty
slot a row high opens above it, below it, or - for "into this one" - INDENTED
underneath it, which is exactly where the workspace will appear. A line between
two rows is a target that has to be aimed at; a slot is a place to drop into.
The slot is a pseudo-element of the row, so the rows below shift down and the
row being aimed at stays where the pointer put it.
A workspace may not be dropped into itself or into its own descendant: the
subtree would be cut off from the root, taking every workspace under it. It is
refused by NOT calling preventDefault(), which is how HTML5 drag and drop says
no, so the cursor refuses while the workspace is still in the air rather than
the drop landing and quietly doing nothing.
AND THE DROP THAT DID NOTHING. With the handles off the drag starts on an
ANCHOR, and a browser puts that anchor's own text into text/plain. The drop read
text/plain first and took the workspace for a BOARD, so it assigned nothing and
the row snapped back - which is why the same drop worked with handles on, where
the drag starts from a span that carries no text. The drag clearData()s before
setting its own type, and the drop decides on the workspace id FIRST.
A CARET FOLDS a workspace's own workspaces away, at the start of the row -
before the drag handle, so the carets of a tree line up whatever the handles
toggle says. It is the control a list has on a board and the one the left menu
itself has, down to the two words in its tooltip, and it answers Enter and Space
because a tree that only opens with a mouse is a tree half the readers cannot
open. A workspace with nothing under it keeps a spacer of the same width, so a
row does not shift sideways the moment it gains its first child.
Open is the default and only the FOLDED ones are stored: a missing key is an
open workspace, so fifty workspaces with two folded is two keys. Remembered in
the same three layers as the rest of this menu - a Session value, then
profile.collapsedWorkspaces, then a cookie for a reader who is not signed in.
The server method takes the id into a DOTTED FIELD PATH and the tree is written
by the client, so the id is checked against ^[A-Za-z0-9_-]{1,64}$ first: one
carrying a `.` or a `$` would address a different field.
The depth is UNLIMITED because nothing counts it: the template draws itself for
a workspace's children, each level indenting by one caret's width with a logical
property, so a right-to-left tree indents from the right by itself.
What a drag does to the tree is a pure module with its own tests -
dropPosition() and moveWorkspace(), the guards included - so the rules can be
proved without a browser, and the page is pinned to calling them rather than
re-deriving them.
Thanks to xet7 !
Member Settings / Change Password renders +atForm(state='changePwd') from
useraccounts, and its submit button carries that package's own classes - a
button.at-btn with no .primary of ours on it. So it fell back to the plain grey
button rule, while the Save button one entry above it in the same menu was
painted with the theme accent. Two buttons, one menu, two looks.
It is named in the SAME rules as .primary rather than given a copy of them, so
the accent and the hover and active states keep one home. Scoped to a popup,
because the login page styles that form its own way and is not what this is
about.
The guard in themeAccents.test.cjs pinned `button.primary {` with the background
on the next line, so adding a selector to that list read as the rule having lost
its colour. It looks for the RULE a primary button gets, whatever else shares
its selector list.
Thanks to xet7 !
The count was there, at the right of the ⋯ menu where it belongs, and it was
invisible.
The accent fills `.js-select-space` - the icon and the name - and NOT the whole
row: the menu button and the count sit after it, on the panel's own light grey.
A rule gave the count a white pill with white text "for contrast against the
filled row", but the count is not on the filled row, so it was white on light
grey. The selected workspace was the one row whose board count could not be
read, and it is the row you have just asked which boards are in.
The rule is gone rather than re-tinted: there is no accent behind the count to
contrast with, so it keeps the same grey pill every other row has.
While here, the count and the menu button are `flex: 0 0 auto` on the desktop
rules too. The name's anchor is `flex: 1`, so without it a long workspace name
shrinks its two neighbours instead of ellipsing itself - which pushes the count
off the row that is supposed to be showing it. The phone rules had said this
already; the desktop ones had not.
The guard in archivePage.test.cjs pinned the white pill as the correct answer,
which is how this survived. It pins the opposite now, and says why.
Thanks to xet7 !
With the handles turned off the drag source was the workspace's ICON alone, and
in practice reordering only worked with the handles on, from the handle.
Two reasons, and the second is why it did nothing rather than being merely
awkward:
* a 16px glyph is a target that has to be aimed at, and the name beside it is
the part of the row a reader is actually pointing at;
* the anchor is not opted out of the page-level dragscroll, which takes the
mousedown, so the drag never begins - and a drag that never begins is
exactly what "reordering does not work" looks like.
The whole icon-and-name anchor is the drag source now, with `nodragscroll` on it
like the handle has, and `user-select: none` while it is the drag source: the
name is TEXT, and a press-and-move over selectable text starts a SELECTION,
which the browser then owns instead of starting an element drag. The handle
never needed that because a glyph in a span is not text anybody selects.
Clicking that same anchor still opens the workspace - a click and a drag are two
gestures on one element, as they are on a board tile with handles off - and the
cursor stays `pointer` rather than becoming `grab`, because opening it is what a
reader does with the row nearly every time.
Still NOT the whole row: it also holds the ⋯ menu and the board count, and a
drag started on those is a drag of something else. draggable lives on the handle
or on the anchor, and the dragstart handler stays on the row, which works
because the event bubbles up from whichever child started it.
Thanks to xet7 !
The panel is fixed at top: 48px - a guess at the height of ONE header bar. The
first header bar wraps to a second and a third row, and the user avatar is the
item that wraps last: on a window where it did, the panel covered the row the
avatar was on, and the avatar - which is inside a z-index: 1000 bar - painted
straight over the panel's own header, beside the X that closes it.
It starts at --wekan-header-height now, which is what the header MEASURES itself
to be, kept current by a ResizeObserver because a bar re-wrapping is not a window
resize. The panel's own fixed header - the row the X is in - follows the same
number, and so does the height it may take: the 100vh - 28px - 36px it had was
the same guess written as a subtraction in two pieces.
That variable is what everything else laid out against the viewport already uses
- the phone sidebar, the All Boards layout and its sidebar - so this is the panel
joining them rather than a new mechanism.
Thanks to xet7 !
The workspace rows in the All Boards left menu drew a ✥ handle whatever the
"Show desktop drag handles" toggle in the first header bar said, and the handle
was decoration: the whole ROW carried draggable="true", so the drag started
anywhere on it. A row that is draggable as a whole starts a drag on the way to a
click, and a workspace row is a row you click - it is how a workspace is opened.
The handle follows the toggle now, through the same
isTouchScreenOrShowDesktopDragHandles helper the board tiles beside it use, so
the two cannot drift apart. With handles ON the handle is drawn and it is the
only place a workspace drag may start. With handles OFF there is no handle and
the workspace's own ICON is what reorders it - the icon rather than the row, so
the name beside it stays a plain click target.
draggable lives on the handle or on the icon, never on the row. The dragstart
handler stays on the row, which works because dragstart bubbles up from
whichever child started it, so the reordering itself is untouched. The grab
cursor follows the same `draggable` attribute - `[draggable="true"]` in the
selector - rather than a second class, so there is one answer to "is this the
drag source right now" instead of two that can disagree.
Thanks to xet7 !
Member Settings / Archive sent the reader to /archive - the full-width page -
and Boards in Archive is a SECTION of All Boards now: a row in its left menu,
drawn beside it like Starred and Remaining. The page is the thing that section
replaced. Landing on it meant the same list of boards with no menu beside it, no
way across to another section without going back first, and the menu row that
says "Archive" was not the row you had arrived at. A menu entry should land you
on the same Archive the menu itself offers.
All FOUR entry points are the same line in a different menu - the board menu,
the member menu, the board sidebar and the All Boards sidebar - so all four go
to /allboards/archive now, built with allBoardsPath(SECTION_ARCHIVE, []) rather
than by spelling the path out in four places that can drift apart. The member
menu also closes itself behind the click, like every other entry in it that
navigates.
/archive is still a route and still renders, so a bookmark from before does not
break; nothing in the UI points at it any more.
The guard pinned FlowRouter.go('archive') in all four files. The behaviour
deliberately changed, so the guard follows it and says why - and it now checks
that the helper really produces /allboards/archive rather than trusting the
call.
Thanks to xet7 !
The Lists/Table dropdown in the first header bar opened a bare list of two
links, while the board's Swimlanes/Lists dropdown - which asks the same question
about the same kind of page - opens with "Board View" above it. A popup with no
title renders no header background and no heading, so the two looked like two
different kinds of control.
It is titled now, and titled with the BOARD's own key. The convention is
<popupName>-title, which would mean an allBoardsViewPopup-title saying the same
two words a second time - and a new key starts as English in all 142 language
files, so most languages would have shown English for a phrase they have
translated for years. Popup.open's titleKey option exists for exactly this.
The guard in allBoardsPage.test.cjs that reads the menu's event map read the
first 160 characters after it, and the comment explaining this handler pushed
the handler past that. It reads the map to its closing line now: a comment is
not a reason for a guard to fail.
Thanks to xet7 !
The house at the start of the first header bar sat further in on a wide window
than on a narrow one, and on none of them on the line the left menu's rows below
it start on.
Its inset is a SUM, and two of the terms were added at some widths only:
* #header carried side padding of its own - 8px below 800px, 16px between 768
and 1024, none between 1024 and 1920, 8px above 1920. Four widths, four
different insets. It has none now, at any width: the inset of this bar is
the bar's own gutter, once. The gutter is on the BAR rather than on its
first item because the bar wraps, and padding applies to every row it wraps
onto while a margin on the first item would indent the first row only.
* .allBoards is the SAME element as .home-icon - span.home-icon.allBoards - so
its side padding lands between the icon's margin and the link, and it was
15px on a desktop against 6px on a phone. It carries padding-block only now.
That 15px was also the gap to the logo beside it; the gap is the 4 + 4 of
the two margins, like every other pair of neighbours in this bar.
And the link's own start padding is the same 6px in every rule, phone rules
included: at 8px, and at 12px on a small phone, a bigger tap target was growing
at the START, which is the inset, rather than at the end.
So the house is at 12 (the bar's gutter) + 4 (the icon's margin) + 6 (the link's
padding) = 22px at every width - the same 22px the left menu's rows are indented
by (4px of margin plus 18px of padding), so the two are one line down the page.
The existing guard added up the three terms it knew about and got 22, and passed
while the house still moved, because the two terms above were not in its sum.
The new one pins the other side of it: no rule, at any width, may add a side
inset of its own.
Thanks to xet7 !
The grip on the left menu's inner edge got its tooltip this release, so
drag-to-resize-left-menu was English in all 142 languages. It is translated
directly now - no external translation service, API or key - from each
language's OWN existing strings: its drag-to-resize-sidebar sentence gives the
pattern and the verb, and its own words for "left" and "menu" replace the
sidebar, so the two controls read as the pair they are.
Applied through fill-translations.mjs, which writes ONLY into placeholders, so a
human translation cannot be overwritten by this; verify-human-preference.mjs
still passes. Filled strings stay local and are not pushed to Transifex.
Klingon, Volapuk, Venda and Tamazight are the least certain: those files hold
another language's text in some neighbouring keys, so their own sidebar
sentence, not the file's other rows, is what each was built from.
Thanks to xet7 !
Spec 08's "notification indicator appears after being mentioned" waited for
`header, #header` to be visible. The first header bar was rebuilt this release
and there is no <header> element and no #header id any more: the bar is
#header-quick-access[role=navigation], which is what specs 18 and 19 already
address it by. The locator matched nothing, so the test asserted that a
non-existent element was visible and failed in all three browsers.
The guard is what changed, not the app - the bar was deliberately rebuilt - so
it now names the bar that is there, and says so in the test for the next reader.
It also asserts the BELL inside that bar, which is what the test is about: the
count beside it arrives asynchronously, so asserting the count would be timing
rather than behaviour, but a notification the user cannot see the bell for is
not a notification.
Thanks to xet7 !
The menu's inner edge - the right one while reading left to right, the left one
under a right-to-left language - now carries a grip, and dragging it changes the
width. It is the same control the right board sidebar has on its own inner edge:
the same 6px strip, the same col-resize cursor, the same nodragscroll so a drag
resizes the menu instead of panning the page behind it, and the same sign flip
on the delta so the widening drag mirrors under RTL rather than needing a second
rule kept in step.
ONE number, on <html>. The width is a CSS custom property,
--wekan-left-menu-width, and everything that needs it reads that: the Admin
Panel's menu, the All Boards one - whose grid track is auto, so the track
follows the menu - and the grip itself, which sits at calc(width - 3px) from the
row's inline start. An inline width on one element could not have done that: the
menu is a different element in a different template on each page.
The default is the number in the stylesheet, and a breakpoint that wants a
different default overrides the VARIABLE, never the menu's width - a rule naming
.side-menu would beat a dragged width, which arrives as an inline property on
<html>, at exactly that one screen size. For the same reason the client REMOVES
the property when nobody has dragged it rather than writing the default into it.
The Admin Panel menu's own width was min(250px, 32vw), which only ever resolved
to 250px above the 800px breakpoint; it is the shared 260px now, because the two
pages draw one menu and a menu has one width.
The grip is positioned against the row/grid around the menu, not inside it: the
menu is its own scroll area, and a handle within it would scroll away with the
entries. Being absolutely positioned it is neither a flex item nor a grid item,
so it adds no column and no gap - which is why the shared menu template can
carry it as a second root and every Admin Panel pane gets it without naming it.
Clamped to 160px..min(600px, window - 240px), with no drag on a phone, where the
menu is full width above the content, and none while it is folded, where there
is no edge to drag. The All Boards menu also takes max-width:100% now, because
the phone rules cap its track at 210px and a fixed width ran out past it.
Remembered in the same three layers as the fold: a Session value first, then
profile.leftMenuWidth, then the wekan-left-menu-width COOKIE when nobody is
signed in - the same cookie mechanism the fold uses rather than the localStorage
the right sidebar's width uses, so one reader's menu is not remembered in two
different places. Saved once, when the drag ends; while dragging, the property
is written directly so the edge follows the pointer without a database write per
pixel. The server method clamps again and refuses NaN, which passes
check(width, Number) and would otherwise store a width no page could recover
from.
Thanks to xet7 !
Folded, the menu was a narrow strip with the caret alone in it. A strip is still
a column: it holds width, it keeps the page from starting at the window edge,
and a caret alone in it is a target that has to be aimed at.
Folded now means gone - no column and no strip - and the way back is the PANE
TITLE beside it: the caret moves to the inline start of the heading, and the
caret and the title are ONE target, so clicking anywhere on it brings the menu
back. Nothing is lost by drawing nothing, because the way back is the largest
thing on the page rather than the narrowest. The heading is drawn even for a
pane with no title of its own - paneTitle() returns {} when no entry is active -
so a folded menu can never be a menu you have lost.
One caret template still, included in both places on both pages, so they cannot
drift. Beside a title it is handed the pane's text as paneTitleKey / paneLabel
rather than the titleKey / label paneTitle() itself uses, so the caret at the
top of the menu, which inherits whatever context the menu sits in, can never
pick a title up by accident. It is an anchor with no href, so it carries
role="button", tabindex="0" and a keydown handler for Enter and Space: without
them the menu could be folded with a mouse and only with a mouse. Its tooltip
and aria-label are one string from a helper that names the pane as well beside a
title, because an aria-label REPLACES the text inside the element and the bare
word would have silenced the heading.
Which element takes the collapsed class differs by page, and that is the point.
The Admin Panel marks its menu: it is a flex row, and a hidden flex item closes
the row up by itself. All Boards marks the GRID, because display:none takes an
element out of a grid but the TRACK it was placed in remains - hiding only the
menu would have left the boards in the auto column with an empty 1fr one beside
them. The grid drops to one column and hides the menu in the same rule, with
!important, because the phone rules set both tracks with an !important of their
own and a phone would otherwise be the one screen still holding an empty column
open.
Thanks to xet7 !
The caret that folds the left menu away - on All Boards and in the Admin Panel -
rendered, pointed down, and did nothing at all when clicked.
Its template draws the caret; the click that folds the menu and the
isLeftMenuCollapsed helper that says whether it is folded live in
client/components/settings/leftMenu.js, and nothing imported that file.
package.json sets meteor.mainModule, so the client is NOT eagerly loaded: a file
nobody imports is not in the bundle at all. The click handler was never
registered, so the caret was a dead control - and an unregistered Blaze helper
is undefined, so the panel never took the collapsed class either, which is why
even the caret itself never turned to point right. The same thing had happened
to adminReports.css, which is why the Admin Panel reports lost their styling.
One import fixes it. The new guard is what stops it happening a third time:
tests/clientBundleImports.test.cjs walks the import graph from client/main.js
and pins that every file under client/components which REGISTERS something with
Blaze - a template's events, helpers or lifecycle, a global helper, a
BlazeComponent - is reachable from the entry point, and that every stylesheet
and template beside them is too. A file that only exports helpers is left alone:
whoever uses it pulls it in. It carries the negative case as well, checking that
removing this very import is reported rather than shrugged off.
Thanks to xet7 !
At the top of both left menus - All Boards and the Admin Panel - there is now a
caret that folds the menu away, and the same caret brings it back. It is the
control a list has on a board: pointing down while the thing is open and right
once it is folded, with the same two words in its tooltip, so there is nothing
new to learn.
Folded, the menu is a strip holding only its caret and the page beside it takes
the width back. Folded rather than hidden outright: a menu with no way back is
a menu you have lost. Everything but the caret goes through a :not() on the
caret rather than a list of names, so a row added later cannot be left sticking
out of a narrow strip. On All Boards the width moved from the grid track onto
the menu itself, because a fixed 260px column would hold the gap open beside a
folded menu.
One state for both pages: they draw one menu, and a reader who folds it away on
one of them has said what they want on the other. It is a Session value first,
so the fold is instant rather than waiting for a round trip, then
profile.leftMenuCollapsed on the user document, and a cookie when nobody is
signed in - a public board has this menu too - through the same cookie helpers
the public list and swimlane collapse states already use.
One caret template for both menus, so the two cannot drift into two different
carets, and the helper that reads the state is global because the panel that
takes the class is a different element in a different template on each page.
Thanks to xet7 !
The right pane began with board tiles and nothing said which list they were.
The page is five lists of boards under one name - Starred, Remaining, Home,
Templates, Archive, and a workspace - so the pane now opens with a heading
naming the one you are looking at.
It is the Admin Panel's own paneTitle template with the same .admin-pane-title
class, so the two pages have one heading at one size and colour rather than two
written twice that drift apart. Only the space below it is set here: the Admin
Panel's gap rule is scoped to its own main-body and does not reach this page.
Drawn once, above the view branch, since the board icons and the Table are two
ways of showing the same section rather than two sections.
Its words are the section's own title key - the same key the first header bar
names the page with and the same one the highlighted menu row carries - so all
three say the same thing. A workspace shows its own name instead, untranslated:
a workspace called "starred" is not the Starred section.
Thanks to xet7 !
Both left menus - All Boards and the Admin Panel - are a light grey card, and
the entry you are on is filled with the theme colour with a white label and
icon: the same treatment the selected tab gets in the bar above it. A menu is
chrome, and tinting the whole card fights the content beside it.
The panel, the labels, the hover, the dividers, the group headings and the
counts are all as they were, and every theme's own header rule names the
selected row again - including the colour slide of a clear* theme, which a
--theme-accent value cannot carry.
The guards and docs/Design/Page/Left-Menu.md describe this arrangement.
Thanks to xet7 !
The Upcoming section is thirty-eight entries, and twelve of them began with
"All Boards" while five more began with "the header bar". The area was repeated
in every summary, so the part that differs started halfway through the line and
the release read as one flat list of forty collapsed blocks.
Each subsection now names an area ONCE, as a bold line with a short description
of what the group covers, and the entries under it drop the prefix: "All Boards
- its sections, its controls, and what the page opens on." then "Home, a
section for the board that opens after login". Not a heading - a ## inside a
release breaks the version list - and the group labels are the areas of the
app, reused across subsections when an area has both a feature and a fix.
Above the first subsection there is now an "In short:" paragraph saying what
the whole release amounts to, with the notable names in bold. It is the first
thing a reader sees, and a release this size otherwise gives no way to tell
what it is about without opening forty blocks.
Every entry, every commit link and every entry body is unchanged; only the
summaries lost their prefixes and the order changed.
CLAUDE.md states both conventions, and two guards check them: that Upcoming
opens with the summary paragraph and that no summary repeats the group it sits
under. The group-line check only reads lines OUTSIDE an entry - an entry body
uses bold for emphasis, and a paragraph opening with one and ending in a full
stop would otherwise be read as a group heading.
Thanks to xet7 !
Both left menus - All Boards and the Admin Panel - were a light grey card with
one row filled in the theme colour. That made the filled row the only part of
the page answering to the theme, so a menu read as a plain grey column with a
coloured stripe in it rather than as a side of the app, next to the header bar
it sits under.
Inverted: the PANEL is the theme colour with white labels and icons, and the
row you are on is a white block cut out of it with dark text. It is also the
easier selection to find - the eye goes to whatever differs from its
surroundings, and one white row in a coloured column differs more than one
coloured row in a white one.
The menu is the colour of the first header bar above it, including the colour
SLIDE of a clear* theme, which a --theme-accent value cannot carry. Every
theme's own header rule now names both menus: before this only six of them
named the All Boards menu, so most themes coloured one menu and left the other
one plain. No theme rule may name the selected row any more - it would paint
that row the same colour as the menu behind it and leave nothing to show which
row is selected - and a guard checks that.
Hover on an unselected row is a wash of white over the theme colour rather than
solid white, which would be indistinguishable from the selected row. The
dividers, the group headings and the counts went with the rows, for the same
reason: a dark rule on a coloured panel is a rule nobody can see.
Two pairs of duplicate count rules were merged while doing this. Each had a
base rule and a colour override with the same selector, which is how a guard
came to read the wrong one.
Documented in docs/Design/Page/Left-Menu.md.
Thanks to xet7 !
The star group in the first header bar is a bookmarks menu now: caret, count,
star. It held boards only - a board could be starred and reached from the
dropdown, and every other page could not, so the one control for "keep this
where I can get at it" worked on one kind of destination and was absent on the
rest, even though those pages have had their own addresses since the All Boards
and Admin Panel URLs landed.
The star on any other page stars THAT PAGE. A bookmark is a relative URL and
the title from the browser tab: relative so it survives the site moving to
another host, and an absolute or protocol-relative URL is refused rather than
cleaned up, because the stored value goes into an href the reader clicks.
The browser tab now says where you are - "Product name - All Boards /
Remaining" - instead of only the product name, so ten open tabs of one WeKan
are no longer ten identical tabs and a bookmark of any of them is named after
the page. The path is the one the header already computes for the title's
tooltip, published through a leaf module rather than worked out twice.
In All Boards / Starred the bookmarks are tiles beside the starred boards, with
the white border the template-container tile carries, the theme's own colour
behind them, and both the title and the URL - the title says where it goes, the
address says what it is. Each tile has its own unstar button, because the star
in the header stars the page you are on.
Dragging a tile past another reorders them, and that is the order of the header
dropdown: the two are views of one array. The move matches both ends by URL
rather than by index, since the two views are rendered separately and an index
from one is a guess about the other.
The list is capped at 50, oldest dropped, because it is a dropdown.
Documented in docs/Features/Board/Starred.md.
Thanks to xet7 !
The All Boards and Admin Panel left menus indent a row by 4px of row margin
plus 18px of link padding, so a row's icon lands 22px from the window edge. The
first header bar sat flush against the edge, so its home icon started 10px in
and every button under it was a little to the left of the menu below.
The bar now carries a 12px inline gutter: 12 + 4 + 6 = 22, the same x as the
menu rows. It is on the BAR rather than on its first item because the bar
wraps - padding applies to every row it wraps onto, while a margin on the first
item would indent the first row and leave the second starting at the edge.
The number is a variable, --wekan-header-gutter, so it has one home, and the
phone overrides that set the bar's padding use it too rather than putting the
bar back against the edge at the widths where the alignment matters most.
The guard adds the three numbers up out of the three stylesheets rather than
pinning any one of them: a test that asserts "18px" passes while the alignment
is broken by a change to one of the other two. It also checks that the two left
menus indent by the same amount as each other - they are meant to look like one
kind of menu - and that no override sets the bar's inline padding to anything
but the gutter.
Thanks to xet7 !
Home no longer takes the top row of the left menu. The top row stays the one
the page opens on - Starred when anything is starred, Remaining when nothing
is - and Home sits under the two of them: after login you are already in the
Home board, so the row that names it is a place to look, not the place to
start.
Taking a board off Home is now the Android launcher's gesture. Pick the board
up in Home and a Remove bar appears above the tiles, only while the board is
actually in the air; drag the board onto it and it turns red; let go and it
asks, and the question says the board itself is not deleted. An affordance that
shows up when the gesture is possible explains itself, and a trash can sitting
permanently under somebody's boards is a button nobody dares press.
Every other target now REFUSES a board that came from Home, in dragover, by not
calling preventDefault - which is how HTML5 drag and drop says no, so the
cursor says no while the board is still in the air. That check cannot call
getData: the drag data store is protected until the drop and only the list of
types is exposed, so the fact lives in the type's name,
application/x-board-from-home, and its presence is the message.
Before this, a board dragged out of Home onto any other row cleared Home on the
way past. That made every drop a Home drop, and a board could leave Home by
accident while being filed into a workspace. One gesture, one destination.
Also repoints two Upcoming CHANGELOG links at the commits that are actually in
history: amending a commit after writing its hash into the CHANGELOG left both
pointing at objects the branch does not contain.
Thanks to xet7 !
One board can be Home: logging in opens it instead of the All Boards page.
That has always existed, set from Multi-Selection's "Set as Home board (opened
after login)", but there was nowhere that said which board it was - the setting
was write-only, and the only way to find out what you had chosen was to log
out.
Home is now a section of All Boards with a row in the left menu, a home icon
and the word Home, a count beside it, and the address /allboards/home. It is
the first row when a Home board is set - it is the board this user starts in -
and still there, further down, when none is, because the place to drop a board
onto has to exist before there is anything in it.
Drop a board on the row to make it Home. A drop replaces rather than toggling:
Home holds one board, and a drop that sometimes set and sometimes cleared would
depend on state the reader cannot see while dragging.
Home is a mark on a board, not a place boards are kept - exactly like a star.
The board stays in Remaining, or in its workspace, and appears in Home as well;
dragging it out of Home onto any other row takes it off Home and leaves it
where it lives. That is why the drag carries where it started.
The server accepts only a board the caller is a member of and that is not
archived, and clears only the board that is actually theirs. Nothing automatic
writes it; Sandstorm's auto-open still persists nothing.
Documented in docs/Features/Board/Home.md.
Thanks to xet7 !
Starred was always the section All Boards landed on. On an account that has
starred nothing that is an empty page with a full one behind it, which reads
as WeKan having lost the boards rather than as a section nobody has filled in.
Now the address that names no section opens Starred when the user has starred
boards and Remaining when they have none, and the left menu puts whichever one
that is on top, so the highlighted row is the first row. Only those two move.
The rule is defaultSection() / menuSectionOrder() in models/lib/allBoardsUrls.js
so the page and its guard read one copy of it. The router no longer answers the
question - it runs before the user document has necessarily loaded - and the
page answers it in an autorun, from the user document's own starred list rather
than from the query that counts boards, whose answer depends on the
subscription and would draw Remaining and then jump to Starred mid-load. An
address that does name a section still wins.
The four menu rows became one row drawn once per section, since an order that
depends on the user cannot be four copies of the same markup.
Thanks to xet7 !
Remaining, then Starred, Templates, and the Archive last.
The order is pinned as a decision rather than left to whichever row happens to
be written first in the markup - the guard reads the four data-types out of the
menu and compares the list, so a reorder is a deliberate edit to a test rather
than something that drifts.
The page still OPENS on Starred: moving a row is not the same as changing which
section is shown by default, and xet7 asked for the first.
Thanks to xet7 !
The Archive had a table of its own, which meant the one place you go to bring a
board back was the one place multi-selection, dragging and the tile layout did
not work. It is drawn with the same board icons every other section uses now,
and the differences are the ones that have to be different: no "Add Board" tile,
because a board cannot be created already archived, and each tile says WHEN it
was archived, which is what tells one old board from another.
Four things had to change for that.
The section's query asks for archived boards - the one section whose query is
the opposite of every other one's - and the menu filter has its own branch for
it, because a workspace assignment survives archiving and the workspace branch
would have hidden most of an archive.
The page subscribes to them, and only while that section is open. The count
beside the menu row comes from a method and says how many there are, not what
they are. The autorun is placed AFTER selectedMenu and boardSearchVar are
assigned: an autorun runs once immediately, so above them it read .get() off
undefined and threw during onCreated - which it did, and xet7 saw it.
The publication sends what a tile draws. It sent seven fields to a template that
reads twelve, which would have rendered grey, nameless tiles.
And the board publication no longer withholds an archived board. It excluded one
unless it was a template, so following a tile's link answered "board not found".
Archived is not a permission: what decides whether the document may be sent is
the membership clause beside it, and that is untouched. The isArchived argument
still governs the CONTENTS, so an archived board opens showing its live lists.
Dropping a board on Remaining brings it back - one board or a whole
multi-selection, which is the point of dragging rather than opening each board's
own menu. It restores through a new restoreBoard method gated exactly as
archiveBoard is: board admin or a global admin. The two must agree, or one of
them is the wrong way round, and the guard checks them against each other rather
than each against a copy of the rule.
Also, from the same screenshots: the home icon and the notification bell now
begin a row at the same x - 4px of margin and 6px of padding each, where the
bell carried 10px from its own stylesheet and the house 8px - and the space
after the username matches the space before the avatar, which it did not on a
page whose bar ends with the name because nothing followed it to provide the
rest.
Thanks to xet7 !
One word, like Starred, Templates and Remaining beside it, and the menu already
says what it is listing - "Boards in Archive" repeated that. It uses 'archives',
an existing key translated in the same 135 languages as the one it replaces, and
the one already used for the archive as a PLACE rather than as an action.
The SECTION title changes with it. A guard pins the two together on purpose -
the header's path names the section and the highlighted menu row names it too -
so renaming only the row would have left "All Boards / Boards in Archive" above
a row saying "Archive", and a reader would have had to work out whether those
are one place. The guard caught exactly that, which is what it is for.
The sidebar's own row keeps its longer label: it sits among Search and
Multi-Selection rather than among one-word sections, and xet7 asked about the
left menu.
One guard fix: the row was sliced out of the markup by a character count, and
the comment explaining this change pushed the label past the end of it. It
slices from the row's own list item to the divider that follows now, so a row
that gains a line does not need the number re-tuned.
Thanks to xet7 !
The workspace highlight was styled for a class that never appeared. `active` is
set by `{{#if $eq id selectedWorkspaceId}}` inside `each nodes` - but
selectedWorkspaceId is a template ARGUMENT, not a helper, and inside `each` the
data context is the NODE. So the bare lookup found nothing on the node, `$eq`
compared an id with undefined, and no workspace was ever active. Two commits ago
I gave that class the theme colour and reported it done; the colour was right
and nothing wore it.
`../selectedWorkspaceId` now, in the test AND in the recursive call - the second
matters as much, because passing the bare name handed undefined to every nested
level, so a child workspace could not have highlighted even once the top level
worked.
The username's own space is split 4px each way rather than 8px stacked after the
name, so the divider on its left no longer touches it. And the avatar's end
margin is no longer zeroed on narrow screens: that was written when the name was
hidden there, so nothing followed the avatar and the gap was waste - the name is
shown at every width now and the avatar was touching it. Second rule in this
stylesheet written for a hidden username and left behind when it stopped being
hidden, so the guard walks every rule that touches the avatar rather than
pinning this one.
And a guard of mine was silently blind. ruleWith() splits the stylesheet on
braces, and I had written a comment containing `a { color: inherit }` - a brace
inside a comment breaks that splitting for every rule after it, so the rule
being looked for was simply never seen and the guard failed against correct CSS.
It strips comments from the whole stylesheet before splitting now, which fixes
it for every comment anyone writes later rather than for this one.
Thanks to xet7 !
Four things, all in the first bar, and three of them were things going MISSING.
The rename pencil vanished on a narrow window. The rules that collapse the home
link's label were written as '.home-icon a' - and the pencil is an anchor in the
same box, so font-size: 0 reduced its icon to nothing too. The home link carries
'header-home-link' now and all 24 of those selectors name it, so a rule about
one anchor stops applying to its neighbour.
The username was collapsed below 800px. That is gone: the bar WRAPS now, so a
name that does not fit costs a row rather than an avatar shoved off the edge,
and who you are signed in as is worth that row. The class that exempted All
Boards and the Admin Panel went with it, along with its helper - with nothing
left to exempt from, it decided nothing, and a mechanism that decides nothing
still reads as one that does.
The avatar sat low. It was floated and nudged up 5px with position: relative -
a float takes an element out of the flex alignment, so it was being lined up by
hand against a row whose height changes with the buttons in it. Its parent is a
centred flex row now and both hacks are gone.
And the gap between the house icon and the page title was 12px against the 8px
every other pair of neighbours has: the icon carries a 4px end margin and the
link added 8px of its own. The link's END padding is 4px now. Its START padding
stays at 8px - that one is the link's inset from the edge of the bar, not a gap
between two things, and the guard says so rather than pinning both to one value.
Thanks to xet7 !
The divider, your avatar and name, the second divider and the sidebar hamburger
are one group now, aligned to the END of the bar - the right edge in a
left-to-right language, the LEFT edge in a right-to-left one. One
margin-inline-start: auto does both, because it is a logical property and the
mirroring is what the browser already does for the writing direction.
This is the one group that is placed rather than packed, and the exception is
the point: everything before it belongs to the PAGE and reads in source order
from the start, while these five are not about the page at all. The divider is
the seam between the two, so the bell stays on the page's side of it.
A box rather than display:contents, unlike the page's button groups. Those
dissolve so their buttons wrap one at a time; these five belong together, and an
avatar left on the first row with its hamburger on the second is worse than the
cluster moving down whole. On a row that wrapped there is no free space for the
auto margin to absorb, so it packs from the start there and fills the row.
The guard that forbids pushing anything in this bar now carries that one
exception by name, and a second guard checks the group holds those five in that
order and that the bell is outside it - so "aligned to the end" cannot quietly
become "everything after the bell", which is a different rule.
Thanks to xet7 !
It took whatever the bar passed down, because a { color: inherit }, so it did
not match Notifications and the rest sitting beside it - and it is a control of
the same bar, not a caption. It is #f2f2f2 now, the value the buttons declare,
and the guard compares the two rules rather than pinning the hex, so changing
the buttons' colour without changing this fails instead of drifting apart.
And one last auto margin is gone. #header-user-bar had margin-inline-start: auto
in layouts.css, which pushed it to the far end of the row - the behaviour
removed everywhere else two commits ago. It survived because it lives in a
different stylesheet than the others did, which is exactly why the guard now
looks for it there too.
Thanks to xet7 !
It had 16px on its left and 4px on its right. The page title's margin-inline-end
was 1rem and the logo carried no margin of its own, so the gap on one side came
entirely from its neighbour and the other side got only the phone toggle's 4px.
Both are 4px now, like every item of this bar, so the gap between any two
neighbours is 8px - the drag-handles toggle included, which was the same fault
fixed one commit ago.
The logo needed a class before it could carry a margin: it is drawn by three
different branches - a custom logo that links, a custom one that does not, and
the stock one - and none of them had one. All three carry header-logo now, so
the spacing does not depend on which is configured, and the guard counts all
three rather than trusting that the one it can see is the only one.
Thanks to xet7 !
It had 18px on its left and 8px on its right, which reads as a hole rather than
as spacing.
The phone/desktop toggle beside it carried 'margin: 0 14px' while everything
else in the bar carries 4px, so the gap to its left was 14 + 4 and the gap to
its right 4 + 4. The toggle carries 4px now, like every other item, so the gap
between any two neighbours in the bar is 8px.
Equalised by making the margins the SAME rather than by picking two numbers that
add up to the same total - the guard checks both, so a future change that
balances the sum by making the parts unequal still fails.
Thanks to xet7 !
The first row ended after the star group with half the bar empty while the
second row was full - Private, Muted, Sort Cards and the rest had all moved down
together.
They sit in one wrapper div, .board-header-controls, and a wrapper div is ONE
item to the bar: when the last button of the group did not fit, the group went
to the second row entire. That is exactly the fault .header-quick-access-end
had, one level further in - fixed there weeks of edits ago and left here because
this group is drawn by a different template.
It generates no box now, and so do the Admin Panel's four tabs. #notifications
and the divider-plus-hamburger stay boxes on purpose: the first also contains
the drawer that opens under the bell, and the second is a pair that means
nothing apart.
Both classes are named in the selector. These elements carry .board-header-btns
too, and the flex rule beside it matches them at the SAME weight - so naming
only the modifier would have left which rule applies to source order, and my
first attempt did exactly that and lost. Checked, 120 against 110, rather than
assumed.
Thanks to xet7 !
With a colour-slide theme the pager had no background at all - clearorange's
header slid and its < and > sat on nothing.
Every fill was followed by 'background-image: none'. That line was there to
defeat the global button gradient in forms.css, and it was already redundant: a
'background' SHORTHAND resets background-image by itself. Once the fill became
--theme-accent-fill, which is a linear-gradient for the six slide themes, the
redundant line stopped being harmless and started erasing the gradient the
shorthand had just set. A flat theme was unaffected, which is why this survived
being looked at.
All seven are gone, including the ones setting a plain colour: the shorthand
covers them, and leaving four of seven would be a rule a reader has to
rediscover.
The guard walks every rule in that stylesheet which fills from the theme and
fails if any of them resets background-image afterwards - the shape, rather than
the seven places it happened to be.
Thanks to xet7 !
Three things.
+ Add Board, the archive's search field and its pager all began flush against
the header, so the first tile looked cut off at the top while the highlighted
menu row beside it had a clear edge above it. The right column starts 10px down
now: the menu panel's 8px of top padding plus its rows' own 2px margin. Derived
from the menu rather than picked to look right once, and the guard checks that
arithmetic rather than the number - so changing the menu's padding without
changing this fails loudly instead of drifting apart.
The pager buttons look like the theme again. They ARE filled with it, but a
disabled one was dimmed to 0.55, and dimming a filled button is exactly what
takes the theme back out: the belize blue #2980b9 arrived on the page's grey as
rgb(122,170,202), a pale wash with no family resemblance to the header above -
and on a one-page list, where BOTH arrows are disabled, that is the whole pager.
0.85 keeps it recognisable at rgb(68,142,191) and still visibly flatter than an
enabled button, which is all the dimming is for; pointer-events: none is what
actually makes it unusable.
And a selected WORKSPACE is filled with the theme, like the selected menu row
above it. It is the same kind of selection, and it was simply left behind on the
old flat #f0f0f0 when the rows were themed - so picking a workspace looked like
nothing had been picked. Its hover is listed on the active rule for the reason
the rows' is: the white hover below sits at equal specificity and, being later
in the file, would wash the selection out as soon as the pointer crossed it.
Its count becomes a light pill, because the mid-grey that read on white
disappears on the accent.
Thanks to xet7 !
Two things, and the first makes the second possible.
Nothing in the bar is pushed to the far end or centred any more. Three rules did
it: an auto margin on the first button after the drag-handles toggle, which sent
everything from there to the right edge; an auto margin on the user menu on
phones; and an auto margin on EACH side of the bell, which centred it in
whatever space was left. Each of them puts a hole in the middle of the row, and
which items land on which side of the hole changes with the window width. The
bar packs from the start and wraps forward now - left to right in a left-to-right
language, right to left in a right-to-left one, which flexbox does by itself
because it follows the writing direction, so there is no second rule to keep in
step.
.header-quick-access-end stays display:contents. It carries no alignment now,
but that is what makes its buttons items of the BAR: as a flex box of its own it
was a single item, and the whole group dropped to the second row together.
And the button names are shown everywhere, at every width. They were dropped
when the bar ran out of room - first below a fixed 1100px, then by measuring
whether the bar had wrapped - and xet7 has now asked for them on boards too. So
the measurement decides nothing, and it is deleted rather than left inert: 111
lines of utils.js, the CSS rule it drove, and 43 lines of guard that described
it. A mechanism that no longer decides anything still reads as one that does.
The bar wraps, and a wrapped second row is a better answer than a row of
unlabelled pictures.
header-keeps-text stays, because it still has a job: the USERNAME, which the
narrow-screen rules collapse everywhere except All Boards and the Admin Panel.
Its comments say that now rather than talking about labels.
An older guard pinned the phone centring on purpose, with a paragraph on why the
bell sat midway. That decision is reversed, so the guard is rewritten to the new
rule and says what changed - and the new one checks the SHAPE: no rule that
positions something within the bar may use an auto margin or a justify-content.
A justify-content inside a button centres that button's own icon and is not
alignment of the bar's items, so the check is scoped to rules on the bar itself.
Thanks to xet7 !
"ResizeObserver loop completed with undelivered notifications", from the fit I
added: it ran inside the observer's own callback, and measuring means SHOWING
the labels and possibly hiding them again - which resizes the very element being
observed. So every notification produced a write and the write produced another
notification, and the observer never settled.
Two changes, and the first is the one that matters.
The bar is re-measured only when its WIDTH has changed. In the steady state
nothing is written at all: with the labels already hidden and still needing to
be, the old code removed the class and added it back on every single cycle.
Verified by running the real function against a bar that wraps while its labels
are shown - one write to settle, then zero, then zero, and two again only after
a genuine width change.
And the fit is scheduled for the next frame rather than called inline, so the
write is an ordinary one whose resize is delivered normally instead of being
raised from inside the callback that is handling the previous resize.
The opt-out path now shows the labels in its own branch, since it returns before
reaching the measurement. Without that a page that opts out would inherit
whatever the last measured page left on the bar - a link from a narrow board to
All Boards would land there with the names hidden - and the guard checks that
branch rather than the statement order it used to rely on.
Thanks to xet7 !
Three things.
The username stays at every width on All Boards and the Admin Panel. The
narrow-screen rules collapse it to font-size: 0 - the avatar stays, the name
stays in the DOM for screen readers - because a full name is easily 100px of a
375px bar. These two pages carry four buttons rather than a board's ten and have
the room, and the name is who you are signed in as. Every hiding selector is
answered with its own copy carrying the class: the widest of them carry
.iphone-device, which outweighs an id and a class on its own, so one short
override would have lost on exactly the narrow screens this is about - checked,
250 against 240, rather than assumed.
The class that marks those pages is renamed from header-labels-always to
header-keeps-text, and its helper to keepsText(). It keeps two kinds of text
now, and a name saying "labels" would have lied about half of what it does.
The bell is named beside its icon, from the key its own tooltip already used,
carrying the same label class as every other named button - so it appears and
vanishes with them rather than needing a rule of its own. Its stylesheet makes
it a fixed 28px SQUARE, which is right for a lone icon and clips a word, so in
this bar it sizes to its content and lays icon and name out like its neighbours.
Scoped to this bar: elsewhere the toggle is still just a bell.
And the grey above and to the left of the All Boards menu is gone. It came from
#content > .wrapper, which is margin-top: 10px and padding: 15px as well as the
width inset - the margin was the grey above, the padding the grey to the left.
My earlier override set width and margin only, so the padding survived; and it
weighed exactly as much as the rule it answered, so which applied came down to
stylesheet order. .wrapper.all-boards-wrapper carries one class more, which
settles it, and the padding is zeroed too.
Three guard mistakes of mine here, all caught by running rather than reading,
and all the same mistake: indexOf on a selector matches whichever rule comes
first, and a selector usually appears more than once - the ordinary styling, a
phone override, the rule you meant. Rather than fix each site again, this file
now has ruleWith(css, selector, declaration), which names a declaration too and
asserts it found exactly one. Its own first version failed as well, because the
regex capture runs from the previous closing brace and so carries the comment
written above a rule; it strips comments from the selector now.
Thanks to xet7 !
Meteor audits that every argument a method was given was handed to check(), and
this one returned 0 for a signed-out caller BEFORE reaching its check(). So
instead of answering 0 it threw
Did not check() all arguments during call to 'getArchivedBoardsCount'
The bug is older than the caller that found it. Its only caller used to be the
archive page, which asks after its own subscription is ready and therefore
always has a user, so the early return was never taken. All Boards now asks for
the count from onCreated - which can run before the user is established, and
which is the right time to ask, since the count is wanted as soon as the page
exists - and the latent bug became an exception on every load.
check() first, then authorise. Validating the shape of what you were given
before deciding whether the caller may have it is the right order regardless: a
malformed argument is malformed whoever sent it. It is also the order the
sibling getPublicBoardsCount already used, which made this one the odd one out.
tests/methodArgumentChecks.test.cjs scans the server methods that take arguments
and check them, and fails on any that can return before the first check() -
rather than pinning this one method, because the bug is a shape and the next
method to grow an early return would repeat it. I verified the guard by putting
the old ordering back and watching it fail, then restoring the fix.
Thanks to xet7 !
Those two carry four buttons each and have room for the words on any window
worth supporting, so the fit measurement leaves them alone. A board carries ten
controls, and is the page the measurement exists for - there it still drops the
names when the bar would wrap.
The header marks the bar `header-labels-always` from the route, and
fitHeaderLabels() returns early when it sees that class. Which page you are on
is the header's business; client/lib/utils.js knows only about boxes, and
teaching it about routes would have put the same question in two places.
It returns AFTER showing the labels, not before. A page that opts out would
otherwise inherit whatever the previous page's measurement had left on the bar -
so following a link from a narrow board to All Boards would land on All Boards
with its names hidden. The guard pins that ordering, because it is invisible
from reading the two lines in isolation.
Thanks to xet7 !
Two bugs in the measurement I added, both visible in xet7's screenshots: the
labels were hidden on a 2560px window whose bar sits comfortably on one row.
It read bar.children, and the buttons are not among them.
.header-quick-access-end is display:contents - it generates no box, so its
buttons are flex items of the BAR while being its own children in the DOM. What
bar.children returns is the four items before it plus that boxless wrapper:
everything except the things that actually wrap. flexItemsOf() descends through
a contents wrapper now, and skips what is not drawn.
And it compared offsetTop against the first item's. The bar is
align-items: center, so everything on one row shares a CENTRE line while their
tops differ by however much their heights differ - and the page title, with its
text and its rename pencil, is much taller than a bare icon. A one-row bar
therefore looked wrapped. Centres are compared now, from getBoundingClientRect
rather than offsets, because offsets are relative to whichever ancestor happens
to be positioned and these items do not all share one.
The tolerance is 6px against a second row that is a whole row away - the
shortest button is 28px - so there is room on both sides of the test.
Verified by RUNNING the real function against a simulated one-row layout (a 28px
title beside a 20px icon, sharing a centre) and a simulated two-row one, rather
than by reading it: false and true respectively, and four items found through
the contents wrapper.
The guard that pins this reads the CODE with comments stripped. Its first
version searched the whole function body for "offsetTop" - which appears in the
comment explaining what was replaced - so it failed on a correct implementation.
That is the fifth time in this file that a guard has read its own explanation.
Thanks to xet7 !
The labels were hidden below 1100px, and a width cannot answer the question. A
board carries ten controls, All Boards four, an Admin Panel page four tabs, and
the words are as long as the reader's language makes them. xet7's screenshots
show both failures at once: at 2266px the labels were shown and the bar wrapped
anyway, and at 1810px they were hidden with a whole row to spare.
fitHeaderLabels() shows the labels, asks the bar whether it wrapped - its last
item sitting lower than its first - and hides them if it did. A tolerance rather
than equality, because items of different heights sit on slightly different
offsets within one row and align-items: center moves them.
It always measures from the SHOWN state. Hiding the labels is what makes the bar
fit, so measuring after hiding would answer "it fits", show them again, and do
that once per frame forever. Removing the class before measuring costs one
reflow per resize and is the whole reason this cannot oscillate - the guard pins
that ordering, because it is the one thing here that is not obvious from
reading.
It runs from the same place that publishes --wekan-header-height, which already
has a ResizeObserver on this bar - and wrapping is exactly what that observer
exists to catch.
Also in this commit, from the same screenshots and the same session:
The pager buttons are FILLED with the theme and their arrows white, instead of
an outline on transparent that put the theme in a 1px border and left the button
a ghost beside solidly themed chrome. They read --theme-accent-fill, a new
variable, because --theme-accent is one COLOUR and a colour-slide theme's fill
is a gradient: a button reading the accent came out flat while the header above
it slid. activeFill() resolves it - a custom pair beats the theme's own slide,
the way a custom colour beats an accent - and the pressed state darkens that
fill rather than replacing it, which keeps a slide sliding.
And both left menus run from directly under the header to the bottom of the
window, flush to its left edge. The Admin Panel's row carried a top padding that
put grey above the menu; the main body carries it now, because content is not
meant to be flush against a bar. Both menus lost their border and corner on the
three flush sides: a rounded corner on a flush edge shows the grey behind it,
which is the gap being removed. All Boards' grid uses column-gap, since a gap is
between tracks and never insets the menu from the window edge.
One correction of mine: activeFill's comment claimed it answers null for an
unknown theme. accentOf answers '' and activeFill defers to it, so it answers ''
too. The comment now says so - the two functions sit side by side and a reader
should not have to remember which returns which kind of nothing.
Thanks to xet7 !
Two things, both of them mine.
Taking the left menu to the window's left edge dropped the wrapper's inset on
BOTH sides, so right-hand content ran to the edge and past it: the archive
pager's ">" was half outside the window, and #content is overflow-x: hidden, so
the half outside was cut off rather than scrollable. The padding goes on the
right COLUMN rather than back on the wrapper - the wrapper holds the menu too,
and an inset there would push the menu off the edge it is meant to sit against.
padding-inline-end, so a right-to-left layout insets the side that is actually
the far one.
And a disabled pager button was opacity 0.4. That was chosen when every theme
accent was a deep colour, where four tenths of it still reads on a light page.
The colour slides added yesterday are built on LIGHT bases - cleargreen #4bbf6b,
clearorange #e67e22 - and at 0.4 an outline in one of those on the page's own
near-white all but disappears. On a one-page list, where both arrows are
disabled, the pager looked broken rather than exhausted. 0.55 is still visibly
weaker than an enabled button beside it, which is the whole job of the rule,
without being a ghost.
The guard pins the inset as a logical property, pins that the wrapper stays
flush so the menu keeps its edge, and pins the disabled opacity between "still
readable" and "still obviously disabled" rather than to one number.
Thanks to xet7 !
clearblue was the only theme in the "clear" category. Five more join it, each a
slide from a light tint down to an existing FLAT theme's own colour, so the two
categories now cover the same colour families: cleargreen from limegreen,
clearorange from pumpkin, clearpink from moderatepink, clearpurple from
wisteria, clearred from pomegranate. A reader who picks either version of a
colour gets the colour they expected.
The rest of each palette is arithmetic on the base, and it is clearblue's OWN
arithmetic - its numbers turn out to be exact. The border under the bar is
base x 0.8, the one above it base x 0.6, the current tab base x 1.15, a selected
minicard the base mixed toward white. Derived rather than guessed, so no theme
can be half-done and a seventh slide is one line rather than a page of hexes.
Each block was generated from clearblue's with one deliberate exception: its
COMMENTS were dropped. They explain clearblue's own history - the flat bar that
looked like strongcyan, the half-white username - and copied into a green theme
those sentences are simply false. Each block carries a header saying what it is
and what it was derived from instead. Every hex clearblue names was mapped, and
the generator refused to run if one was not: an unmapped colour would have
stayed blue inside a green theme.
Board Settings, Member Settings and Admin Panel / Settings / Visibility are one
picker with a scope, grouping by THEME_CATEGORIES - so adding each theme to the
category and to ALLOWED_BOARD_COLORS puts it in all three at once, gives it the
category's two custom colours (a slide has two ends), and validates it on the
server through the same list.
And a bug of my own, from making Boards in Archive a section of All Boards:
clicking it highlighted Remaining. The workspaces-tree autorun treats anything
that is not a section as a workspace id, and a workspace missing from the tree
was deleted, so it resets to Remaining - but the section names were written out
by hand there, and `archive` was not among them. It reads ALL_BOARDS_SECTIONS
now, the same list the router and the URLs use, so a sixth section cannot go
missing from it.
Guards: every theme in "clear" must be a gradient with two DIFFERENT ends whose
solid end is the accent everything outside a board reads - a slide theme with a
flat bar is a flat theme filed in the wrong drawer, which is exactly what
clearblue's own first bar was until it was fixed. Each must paint the picker
swatch, the board canvas, and both selected menu rows. And the archive-highlight
fix is pinned including its import, because a missing import there is a
ReferenceError at runtime that no unit test would see - which is how it first
showed up.
Thanks to xet7 !
The bar showed the whole path - All Boards / Workspaces / Engineering /
Backend, Admin Panel / Settings / Version. The path is what the page IS, but it
grows: a workspace nests as deep as its tree does, and this bar is the one strip
always on screen and already short of width. It shows the root now, and the
whole path is the title's tooltip.
headerTitleFullPath() resolves it to one string, because a title attribute is
plain text and cannot hold the {{_ }} calls the visible version used. It reads
the SAME headerTitle() and the same trail the visible name does, so the two
cannot disagree about where you are, and a workspace's own name still does not
go through the translator.
The trail became a plain function rather than a helper. The first version of the
tooltip called sibling helpers through "this", which in a Blaze helper is the
DATA CONTEXT, not the helpers object - so it would have resolved to nothing at
all. Both callers use the function now, and the guard says why, because that is
a mistake worth only making once.
The headerTitleTrail helper is deleted rather than left: nothing draws the trail
inline any more, so it was a helper no template calls.
Thanks to xet7 !
The theme lived on the second header bar, and most pages no longer render one.
Three themes were wrong once it moved to the first.
clearblue is a colour SLIDE, and the slide was only ever on
'#header #header-main-bar' - the inner element of the second bar. The first bar
took the flat background-color beside it, #00aecc, which is strongcyan's colour
EXACTLY: choosing clearblue painted the header strongcyan. The slide is on
#header-quick-access now.
natural and modern each had a LATER rule repainting the first bar the near-black
shade it wore as a thin strip above the coloured main bar - #2d392b and
#333 !important. Picking Natural painted the header near-black and Modern
charcoal, whatever their own colours were. Both overrides are gone; the shared
rule provides each theme's own colour. Modern's padding and font-size went with
it: those sized the strip, and this bar sizes itself like every other theme's.
clearblue's username is full white. Half-white was right when this bar was a
secondary strip and its username and board links were background furniture; it
is the header now, and no other theme dims them.
And a slide is not a colour. Everything outside a board reads --theme-accent,
which is right for the eighteen flat themes but cannot carry a gradient - so the
All Boards left menu's selected row sat flat #00aecc beside an Admin Panel row
that slid. It is named in clearblue's own rule now, as the Admin Panel's row
always was, and its extra class makes it win over the accent rule.
tests/themeAccents.test.cjs pins every theme painting the first bar, painting it
its own colour, and NO TWO THEMES painting it the same - which is the check that
would have caught clearblue looking like strongcyan.
Two mistakes of mine here, both caught by the guard rather than by reading: the
first version of that guard anchored on a selector prefix the flat rule and the
gradient rule share, so it read the wrong rule and passed; and the first attempt
to add the tests to this file used a harness shape the file does not have, so
they silently never ran.
Recorded and NOT changed: cleanlight's accent is a mid-grey scraped from a
'#header ul li:hover' rule, because it has no plain #header rule at all. Its bar
is near-white and cannot be an accent for white text, so the grey is right by
accident. Choosing a deliberate one is a call about a colour, not a bug fix.
Thanks to xet7 !
The newest run (../log/2026-08-01_03-14-39) is the first since this work: 18
Playwright failures in every one of the three browsers, and one mocha failure.
The run before it had none. All of them are mine, and all of them are tests
describing markup I moved rather than behaviour that broke.
.js-toggle-sidebar was eight of them. The hamburger moved to the first header
bar and became .js-toggle-page-sidebar - one control that opens whichever
sidebar the page has. The page object and the one spec that named it directly
now name the new one. Its handlers in boardHeader.js and sidebar.js are DELETED
rather than left: markup for them no longer exists anywhere, so they were dead
code that reads as a working feature, and the injected CSS rule in boardBody.js
is repointed at the class that exists.
#header-main-bar was two: the RTL layout test used the second header bar as its
"the page has visible text" anchor, and the three list pages it checks no longer
render one. It anchors on the first bar's page title instead, which is where
those titles went.
The rules controls were three: they are a view of the shared page sidebar now,
so the specs open it first. The All Boards search field was one, for the same
reason. The card's copy-link button was two - it is a row of the card's actions
menu, so the page object opens the menu, and the test asserts what reached the
CLIPBOARD rather than reading an href that no longer exists.
That clipboard is RECORDED, not read back: reading it needs a clipboard-read
permission only Chromium can be granted, so a test that read it would pass on
one browser of three. WeKan copies through navigator.clipboard.writeText, so
wrapping that captures the real value everywhere - and it is a stronger check
than the old test, which accepted "no href, so click it and see that nothing
threw" and would have passed even if the button copied nothing.
Two were addresses: /templates now redirects to /allboards/templates, and the
admin spec navigated to /admin-reports and then clicked a menu row. Every Admin
Panel pane has an address of its own, so it goes straight to
/admin/problems/files - no redirect to race with the menu rendering.
The mocha failure is from the board-roles capability table:
allowIsBoardMemberCommentOnly asks that table now, and the table reads
board.members, while the test's stub offered only the old hasMember/hasReadOnly
methods - so it answered false for everyone. The stub is a members array now,
and a second case pins what the old one could not express: it is the ROLE that
decides, not membership, so isNoComments, isReadOnly and an inactive member are
each false. Every expectation was checked against the real function before
being written down.
Not verified by running: Playwright needs the app server and browsers, which
this sandbox does not have. Every edited file parses, and the 292 node suites
pass.
Thanks to xet7 !
The menu had the Admin Panel's panel and its accent, but its rows were still
laid out the old way - rounded pills, floating inside a panel that added 8px of
its own padding on top of each row's margin. Two menus wearing the same colours
and reading as two different menus.
The rows now use the same margins (2px 4px), the same block padding and
reading-direction indent (12px / 18px), and NO rounded corners, so a selected
row is a block across the menu rather than a pill inside it. The panel's own
padding goes with them: the rows carry their margin themselves, and padding on
the panel inset every row a second time.
The dividers line up with the rows rather than running past their ends, and
"Workspaces" takes the Admin Panel's group-heading treatment - smaller,
uppercase, quieter - because it names a group and is not an entry of one.
Thanks to xet7 !
The menu was styled like the Admin Panel's but did not sit like it. The Admin
Panel gets that for free: .setting-content is OUTSIDE .wrapper, absolutely
positioned at full width and height, so its menu is against the left edge and
runs to the bottom. All Boards is in normal flow inside .wrapper, which is
width: calc(100% - 28px); margin: 0 auto - so the menu floated 14px in from an
edge it is meant to look attached to, and stopped wherever its content did.
This page opts out of that inset, and takes its height from 100vh minus
--wekan-header-height - the height the header MEASURES itself to be, because the
bar wraps to a second row and is not one fixed number. A MINIMUM, not a fixed
height: a page with more boards than fit still grows past it, and grid items
stretch to the row by default, so the menu fills whichever is taller without
asking for a height of its own.
The phone rules already override .boards-layout's height through a higher
specificity selector, so the inner scroller they set up is untouched.
The guard reads the .wrapper declaration across layouts.css rather than inside
the first block it finds: .wrapper is declared more than once there, and the
first block carries only the comment explaining the sizes.
Thanks to xet7 !
Boards in Archive moved again, to under Remaining, and it no longer LEAVES this
page. It opens as a section drawn beside the left menu: selecting something from
a menu and then losing the menu is a menu that throws itself away. Its address is
/allboards/archive, the same shape as the other four sections, and the template
is the archive's own so its search and its pager come with it. The standalone
/archive page stays for a direct link, and the sidebar's row still goes there.
It carries a count like the three lists above it, asked of the SERVER rather than
counted from what the page has - All Boards does not subscribe to archived boards
at all, its query is archived:false, and the archive's publication is paginated
to 30, so counting minimongo would answer 0 on a fresh load and something
arbitrary later. getArchivedBoardsCount is the method the archive's own pager
already uses.
Boards can be dragged onto that row to archive them, from any of the four lists
or from a workspace. The left menu already accepts a drop on Remaining, so this
is the gesture already in the reader's hand; the alternative was three clicks
through Multi-Selection. It asks first, because a drop is easy to make by
accident - a board dragged to reorder that lands one row low - and it refreshes
the count afterwards, which a method call will not do on its own.
The Multi-Selection button in the header bar now shows that it is on and offers
the way off, the same pair the board's own has. It looked identical either way,
so the one control that changes what clicking a board does gave no sign it had
changed it. Its helper is registered on the template that uses it: a Blaze
template cannot see a sibling's helpers, and that failure is a hard render error.
The Sort Boards popup is titled from the key that phrase already has, which is
what gives it a header and with it a close button.
And the left menu is styled like the Admin Panel's - a panel with its own
background, border and rounded corners, a selected row in the per-user theme
accent with white text, a white hover with a shadow, and scrolling inside itself
rather than spilling past its own corner. WeKan has one kind of left menu.
One guard was rewritten rather than widened: it looked for a handler within 400
characters of its event map, which has to be re-tuned every time a comment is
added inside that map. It is scoped to the block now.
Thanks to xet7 !
The rules above and below the Workspaces section were a #e0e0e0 hairline - the
same grey as the menu's own right edge - and too faint to separate anything.
They are 2px #888 now, a grey this stylesheet already uses. A light grey needs
AREA to be seen at all, and area is what turns a divider into something that
reads as another row of the menu; a dark grey is legible as a line, so it stays
a line.
2px rather than 1: at 1px a mid-tone rule can land on a half-pixel boundary and
be drawn as two lighter rows on a fractional-scale display, which is the same
faintness this was meant to fix.
Thanks to xet7 !
It was a button in the first top header bar beside Sort, Search and
Multi-Selection. Those three act on the boards in front of you; Boards in
Archive is a PLACE you go instead, so it sits with the other places - a row of
the left menu, below the Workspaces section, drawn like Starred, Templates and
Remaining rather than as a stray link.
The Workspaces section now has a rule above and below it. The left menu is three
kinds of thing in one column - the three board lists, the workspaces tree, and
the archive - and without them the tree ran into its neighbours as if it were
more of the same list. One hairline in the same grey as the menu's own right
edge, not a default hr: that is a beveled 2px ridge and reads heavier than the
border it sits inside. The workspaces header's own top margin goes with it, or
the rule and the margin together read as a gap twice as wide as anything else in
the menu.
The click handler moved WITH the markup, into the map of the template that now
draws it. A Blaze event map only sees events inside its own template, so a
handler left behind in allBoardsHeaderButtons would never fire and the row would
silently do nothing - which is exactly what happened to this button once before.
The guard checks both halves: nothing left in the old map, and a handler in the
new one.
Two existing guards caught the move rather than being edited into agreement with
it, and each now says where the button went.
Thanks to xet7 !
Search was the same shape as Filter: it only ever OPENED, so clicking it while
the sidebar was already showing search did nothing visible and the only way back
was the sidebar's own X, somewhere else on screen from the thing you just
clicked.
The two now share one rule. models/lib/filterButton.js is
models/lib/sidebarViewButton.js, because a module named after Filter answering
for Search would be a name that lies, and one toggleSidebarView() helper applies
it - two copies of "which way does this click go" would eventually be two
answers.
The exception stays Filter's alone, and is now named as what it is rather than
assumed: with a filter ON the sidebar is the one place that says what is being
hidden from the board. Search has no such case - its results are inside the
panel and it hides nothing from the board - so it passes false and always
toggles.
The Admin Panel's four tabs carry their name beside the icon, each from the same
key its tooltip uses, and lose it below 1100px with every other label in the
bar.
Thanks to xet7 !
Sort Boards, Search, Multi-Selection and Boards in Archive each carry their
name beside the icon now, from the same translation key their tooltip uses, and
lose it below 1100px with every other label in the bar.
The board's visibility and watch buttons got theirs in the previous commit; the
changelog paragraph that lists which buttons are named has now grown twice, so
it is re-wrapped as a whole rather than patched line by line - patching it left
a ragged 20-character line in the middle.
That paragraph is also what broke tests/changelogFormat: the guard tolerates 250
over-long lines of legacy content and my edit made it 251. It was failing when I
committed the previous change, because I piped the test run through tail and the
pipe swallowed its exit code. Fixed here, and worth remembering: the suite runner
must be checked by its own output, not by a pipeline's status.
Thanks to xet7 !
Private / Public and Watching / Tracking / Muted are the state of the board you
are looking at, and a padlock or a crossed-out bell is a poor way to read it.
Both carry their name beside the icon now, and both lose it below 1100px with
every other label.
The label is the same DYNAMIC expression as the tooltip - {{_
currentBoard.permission}} and {{_ watchLevel}} - so one line of markup covers
all five values. Written out per value it would be two lists of words to keep in
step with each other and with the board.
Thanks to xet7 !
Sort Cards, Filter, Search and Show Dependencies carry their name beside the
icon now, and so does the board's Multi-Selection alongside All Boards' and the
two view menus. A tooltip is the one place a name cannot be read without
hovering.
Below 1100px every one of those labels goes and the buttons are icons again. A
label is worth several icons' width, so on a narrow window keeping them costs
more buttons off the first row than the names are worth - and the tooltip is
still there for all of them.
All of them together, not some. Half the buttons named and half not reads as a
bar that has been half finished, and WHICH half you got would depend on which
words happen to be short in your language. One rule hides the one class they all
share, so none can be left behind - which is also why the first attempt at this,
an is-optional class on four of the six, was not worth keeping.
The threshold is the window rather than a container query: what a container
query would measure is this bar, and this bar is as wide as the window by
construction.
Each label uses the SAME translation key as its own tooltip, so a button and its
tooltip cannot say different things, and a button whose name changes with its
state - Sort is on, Hide dependencies - says the state in both places. The guard
checks that pairing per button rather than only that a label exists.
Thanks to xet7 !
The bar wraps when its buttons do not fit, but everything after the
drag-handles toggle was wrapped in .header-quick-access-end, and a nested flex
box is ONE item to the bar - so the whole group moved to the second row
together. The first row ended halfway across with nothing in the rest of it
while the second row was crowded, which is the opposite of what wrapping is
for.
The group is display:contents now: its box is not generated, so its buttons are
items of the BAR and wrap one at a time. The second row takes only what did not
fit on the first.
The group stays in the markup - it is what says where the end of the bar begins
- and the auto margin moves to its first child, the item that has to push the
rest to the end. That still mirrors by itself in a right-to-left language,
because margin-inline-start is logical. On a row that wrapped there is no free
space for the margin to absorb, so those items pack from the start, which is
what fills the row instead of stranding it.
Thanks to xet7 !
The popup had no title, so it rendered as a no-title pop-over: no header, and
the close button lives in the header, leaving nothing to shut it but clicking
away.
It is titled "Starred Boards" now, from starred-boards - the key the app
ALREADY has for that phrase - through a new titleKey option on Popup.open. The
convention is <popupName>-title, which is right when a title is that popup's own
words and wrong here: adding starredBoardsPopup-title would put a second copy of
one phrase into all 147 language files, English in every one of them at first,
so most languages would show English for something they have already translated.
The convention still applies to every popup that does not pass a key.
The board's own Multi-Selection button carries its name beside the icon now. All
Boards' got its label earlier; this is a different button in a different file and
was left behind - a check-box outline says nothing about what either does.
Thanks to xet7 !
The bar drew TWO stars in a row - one on the starred-boards dropdown and one
for the board you are on - which reads as a single control somehow drawn
twice. The dropdown has no star now: it is a caret and the COUNT of starred
boards, which is what it is about, and the count is shown even at zero
because it is the button's only label and a bare caret says nothing about
what it opens.
The board's own star sits immediately beside it, and the two are wrapped in a
rounded outline of their own, shaped like the phone/desktop toggle's but
WHITE where that one is black: the toggle is a white box sitting on the bar,
so a dark border shows against it, while these buttons sit on the bar's own
colour and need a light one. Without an outline the two read as unrelated
icons that happen to be adjacent - which is what made the two stars confusing
in the first place.
The star is its own template so the bar can place it here rather than among
the board's other controls further along. A Blaze event map only sees events
inside its own template, so its helpers and its click came with its markup,
and the copies left behind in boardHeaderButtons are gone.
The two view menus and Multi-Selection carry their name beside the icon
again. I had made them icon-only earlier and xet7 asked for the words back;
that is the better call - six view glyphs are six things to learn, a
check-box outline says nothing about multi-selection, and a tooltip is the
one place a name cannot be read without hovering. The bar wraps to a second
row when it runs out of width, which is what makes the word affordable now
and did not before. Sort, Search and Boards in Archive stay icons: those
glyphs are well known. A label does not wrap mid-button - two lines inside a
one-line button is worse than the tooltip was.
Clicking Filter while the filter sidebar is already showing closes it. It
only ever opened, so a second click did nothing visible and the only way back
was the sidebar's own X, somewhere else on screen from the thing you just
clicked. Not while a filter is ON, though: the sidebar is then the one place
that says WHAT is being hidden from the board, and closing it would leave a
board showing a subset of its cards with nothing on screen to say so - so it
stays open, and the X beside the button is what clears the filter.
"Showing the filter" is open AND on that view, because a sidebar open on
Activities is not showing the filter and clicking Filter there has to switch
to it rather than close the panel.
models/lib/filterButton.js holds that rule so "which way does this click go"
is answered once, and the guard checks all four combinations without a DOM.
tests/mobileModeConsistency stopped expecting js-star-board among the board
header buttons and says where it went instead; tests/headerBars pins the
group, its light-against-the-bar outline, the labels, and the filter toggle.
Thanks to xet7 !
A card has had an address since there has been a card route. A swimlane and a
list had none: "the Backlog list of this board" could only be sent as "open
this board and scroll down". Worse, List.absoluteUrl() answered with the URL
of whichever CARD the cache returned first for that list, so the one place
that did offer a list link - listMorePopup - showed a card's address, and an
empty box for an empty list.
Both have their own now:
/b/<boardId>/<slug>/swimlane/<swimlaneId>
/b/<boardId>/<slug>/list/<listId>
FIVE segments against the card route's four, which is the whole reason the
three can coexist: a card URL cannot match these and these cannot match a
card. A guard checks all nine combinations, because losing a segment would
silently turn a link to a list into a link to a card called "list". The kind
is a word in the path rather than a query parameter - it is part of what the
address names, and a reader can see which is which. config/router.js
registers the routes from the same constants the links are built from, so the
two cannot disagree about the path.
Following one brings the thing into view. The route cannot do that: it runs
before the board has rendered, and on a board that is already open it runs
without re-creating anything, so at that moment there is nothing on screen to
scroll. It names what to reveal in a Session value and the board body reveals
it, WAITING for the element rather than assuming it - swimlanes and lists
arrive as their subscriptions do and a large board renders in more than one
pass, so a single look on the next tick finds nothing on a cold load. It
gives up after a few seconds rather than spinning: a link to a list that was
archived names an element that is never going to exist, and the board is
still the right place to have landed.
The Session value is cleared when acted on AND when given up on, so it is a
one-shot instruction about the address you followed rather than a standing
order that re-scrolls on every later render - and cleared BEFORE the scroll,
because scrollIntoView can re-enter the autorun through the reactive read.
Every other board route clears it too: without that, opening a card after a
list link would scroll the board away from the card it had just opened.
The element ids are the ones the templates already carry, held as a map and
not derived by a rule. Deriving one name from another is how
allBoardsMultiselectionSidebar happened, and this failure is silent - nothing
is found and nothing is reported - so a guard checks each id against the
markup that draws it.
All three are copied the same way, from the FIRST row of the hamburger menu,
with the link icon and the name beside it. The card carried this as an icon in
its title header named only by a tooltip, which is the one place a name cannot
be read without hovering; that button is gone, and so are its handler and the
"Copied" tooltip only it used - a handler for markup that no longer exists is
dead code that reads as a working feature.
The copy row sits ABOVE every permission check in those menus. Copying an
address is reading, not editing: somebody who may only read the board can
still tell a colleague which list they mean, while everything else in those
menus changes the board and stays guarded. It copies absoluteUrl(), because
what lands in the clipboard is pasted into a chat and a relative path is only
a link inside this page.
copy-link-to-clipboard is added to all 147 language files, in the same
position in each. 119 are really translated, each derived from that
language's OWN existing copy-strings rather than invented. The 16 that keep
the English placeholder are the ones whose existing copy-strings are already
in the wrong language - Romanian holding Italian, Esperanto holding Spanish,
Hindi holding Gujarati - where deriving from them would only spread the
error; the en_* variants are English by design.
docs/Design/Page/Header.md is written at last. Twenty-seven comments have
been pointing at it since the header restructure began and it had never
existed, so every one of them was a dead reference.
Thanks to xet7 !
Sort, Search, Multi-Selection and Boards in Archive were rows of the right
sidebar's home view, so reaching any of them meant opening a panel over the
boards first. They are icons in the FIRST top header bar now, left of the
notification bell - exactly where a board's own controls are - each named by
a tooltip, like every other button of that bar. They have now been in three
places: the page's own body, a second top header bar, and that sidebar view.
The home view was the only thing the hamburger opened, so All Boards no
longer offers one, nor the divider before it. A menu to reach what is already
one click away is a step with nothing in it. NO_HAMBURGER_ROUTES in
models/lib/pageSidebar.js is what says so, and a board is deliberately not in
it: what a board's sidebar holds - members, labels, activities, settings - is
not in the bar and has nowhere else to be opened from.
The sidebar is still reachable, and that mattered enough to guard: removing
the hamburger removes the only generic way in, so Search and Multi-Selection
opening it ARE the way in, and if their event map went the panel would become
unreachable and both features would stop existing. The home view is not dead
markup either - it is what the back arrow of the other views leads to.
Boards in Archive is drawn in two places now. A Blaze event map only sees
events inside its own template, so each copy needs its own handler; a copy
with markup and no map is a button that silently does nothing, which is
exactly what happened to that button once before. The guard checks both
copies rather than one.
Three existing guards had to change, and each says why: the page-body check
now scopes itself to the board-list markup rather than the whole file, since
the controls live in that file again; the archive guard checks both copies
instead of asserting the header must NOT handle it; and the hamburger guard
learned that a page can have a sidebar and still not be offered one.
docs/Design/Page/All-Boards.md described a second header bar that no longer
exists, so its controls section is rewritten to what is there.
Thanks to xet7 !
Sub, sub-sub, sub-sub-sub and further already worked in both places xet7
asked about - the URL is /allboards/workspaces/workspace-name/sub-name/
sub-sub-name, and the first header bar reads All Boards / Workspaces /
Workspace Name / Sub Name / Sub Sub Name - because nothing in the path
counts levels: workspaceSlugPath recurses to the node, splitWorkspacePath
splits on every slash, workspaceIdForSlugPath walks each segment, and
workspaceNamePath names each one. What was missing is a guard saying so, so
nothing later caps it silently. A cap would be invisible until somebody made
a workspace one level deeper than whoever wrote the cap imagined.
Two tests in tests/allBoardsUrls.test.cjs. The first builds a six-level tree
and follows the deepest node all the way round: the URL carries six segments,
the route splits it back into six, it resolves to the LEAF rather than to an
ancestor, and the header names all six in order - on the stub slugifier and
again on limax, which is what actually runs. It also pins the route pattern
itself, `:path*` and not `:path?`, since that is what allows the trail to be
captured whole; with `?` a sub-sub-workspace would quietly resolve to its
parent.
The second walks EVERY node of the fixture tree, ancestors included, not only
the leaves: an ancestor is a workspace you can open too, and its URL must be
its own trail rather than the prefix of one of its children's.
Thanks to xet7 !
The bar said "All Boards" on Starred, on Templates, on Remaining and inside
every workspace - it named the page and not the list you were looking at. It
shows the path now: All Boards / Starred, All Boards / Workspaces /
Engineering / Backend.
The section's words are the LEFT MENU's own key, so the title and the row
highlighted beside it always say the same thing. A workspace's words are its
NAME rather than the slug the URL carries - the slug is lowercase and
hyphenated and is not what the workspace is called - and the name is printed
as text rather than translated: it is what a person typed, and a workspace
called "starred" is not the Starred section.
A trail segment that names nothing stops the walk and the title shows what
was still real, so a stale link names the part of the path that still exists
rather than nothing at all.
The header walks the tree itself, from the URL and from
user.profile.boardWorkspacesTree, which is where the All Boards page reads it
from too. It cannot ask the page: the header is a separate Blaze instance,
and importing a page module from here once ran it before its own template was
registered, and the throw aborted every module after it.
The Admin Panel's two segments and these are ONE list now, not a helper per
segment. The two pages do not have the same number of them - the panel always
has two, a workspace has as many as its tree is deep - so a fixed set of
helpers could only ever serve whichever page was written first. Each entry is
one of the two forms a left-menu entry already has: a key for something
translated, a title for text that must not go through the translator.
Guards in tests/headerBars.test.cjs pin every section's key against the left
menu's markup and against en.i18n.json, that an unknown section titles the
default rather than nothing, the name walk including both ways it can stop
early, that the tree comes from the user document, that a workspace name is
pushed as a title and never as a key, and that a board has no path at all
because its own title is the whole name. tests/adminUrls.test.cjs follows the
panel's pane title into the same list.
Thanks to xet7 !
The summaries carry the hash in the href, so they can only be written once
the commit exists. The two header-bar fixes are the commit before, the Admin
Panel move is this one's parent.
Thanks to xet7 !
The four pages sat at the TOP level - /settings, /people, /attachments - as
if they were pages of the app rather than of the Admin Panel, and
/attachments is also the path the file server serves attachments from
(server/routes/universalFileServer.js), so the panel and the files were
claiming one address. They are under /admin now: /admin/settings/version,
/admin/people/login, /admin/problems/database, /admin/attachments/backup.
The default pane is named too. It used to be left implicit in a bare page URL
- /settings rather than /settings/version - so the address of "Settings" and
the address of "Settings showing Version" were one string. The address is
meant to say where you are, and the first pane is somewhere too. The bare
/admin/settings still resolves; it redirects to the pane's own address rather
than being a second name for it.
Every path the panel used to answer on redirects - the bare old address, the
old one with a pane in it, and /setting, /information, /translation. They are
built from the same map the URLs are, so a page cannot be given an address
without also being given its redirect, and a bookmarked
/settings/global-webhooks keeps its pane instead of landing on the top of the
panel.
The title beside the house icon names the same three things: Admin Panel /
Settings / Version. The page's words are the tab's own key and the pane's
words are the menu row's own label, so the title, the tab that is marked
active, and the row that opened the pane cannot say different things about
one place. The pane title is read from the URL rather than from the page,
because the header is a separate Blaze instance from the Admin Panel's pages
and must not import them - doing that once ran a page module before its own
template was registered, and the throw aborted every module after it. That
makes ADMIN_PANE_TITLES a second copy of the menus' labels, and a guard
checks every entry of it against the real menu entry for that pane.
The button of the page you are ON keeps the hover background, permanently and
a shade darker. The tabs had marked themselves .active all along, but the only
rule that drew it was scoped to #header-main-bar - the second bar, which those
tabs no longer live in - so the state had been computed and invisible since
they moved. Darker than the hover rather than equal to it: hovering the tab
you are already on must still look like a hover.
--wekan-header-height measured #header - the SECOND bar - from when every page
had one. Most pages have none now, so on those the variable was 0 and
everything laid out against it started at the top of the WINDOW, underneath
the first bar: the All Boards sidebar covered it. It measures both bars now,
as the BOTTOM of the lowest rather than a sum of heights, so any margin
between them counts and a bar that is absent contributes nothing without a
special case - and each bar has its own ResizeObserver, because the first one
wrapping to a second row is a resize of that element and of nothing else.
A divider after the notification bell. Everything before it belongs to the
page - its controls, its view menu, the panel's tabs; everything after
belongs to you - help, your account, the sidebar toggle. Without it the run
of icons reads as one list of unrelated things.
The URLs went through a hyphenated shape - /admin-settings-version,
/allboards-starred - before xet7 asked for the slash form instead. All
Boards is back to exactly what it was; only the Admin Panel moved.
Guards: tests/adminUrls.test.cjs pins the /admin prefix, that every pane is
named in its URL including the default, that each page records the path it
used to answer on, that the three redirect shapes exist and carry the pane
along, and that every pane title matches the real menu entry's label - both
directions, and a translation key has to be a real one. tests/headerBars
pins the divider and the darker active mark; tests/sidebarBelowHeader pins
that the height covers both bars. tests/adminOldUrlRedirect's route-body
helper now understands a route whose path is an expression, since the panel's
four take theirs from adminRoutePath(page) so the route and the links cannot
disagree.
Thanks to xet7 !
Six things, all in the one bar that is always on screen.
The bar wraps. It was one row with `overflow: hidden`, so a button that did
not fit was not drawn at all - and a button that is not drawn gives no sign
that it exists. It is `flex-wrap: wrap` now with a row gap, and every height
on it became a minimum, including the four phone rules that pinned 40px or
48px: a content box with a fixed height cannot hold two rows, so it would
have cut off exactly what the wrap was for. A phone is where the buttons run
out of room first, which makes it the last place that should hide them.
Everything after the drag-handles toggle hugs the end of the bar, from one
`margin-inline-start: auto` on a new `.header-quick-access-end` wrapper. That
is a LOGICAL property, so a right-to-left language mirrors it by itself
rather than needing a second rule kept in step with the first.
Both view menus are icons now. The board's said "Swimlanes" beside its icon
and All Boards' said "Lists"; the name is in the tooltip instead, where the
other buttons of that bar already keep theirs. `boardViewName` maps the six
views to their own keys, so the tooltip says which view is on.
The button of the page you are ON keeps the hover background, a shade darker
and permanently. The Admin Panel's tabs have marked themselves `.active` all
along, but the only rule that drew it was scoped to `#header-main-bar` - the
second bar, which those tabs no longer live in - so the state was computed
and invisible from the moment they moved. Darker than the hover rather than
equal to it: hovering the tab you are already on must still look like a
hover.
The Admin Panel says which of its four pages is open - "Admin Panel /
Settings". All four of its routes answered the same key, so the bar said the
same three words on every one of them. The second segment uses the same
translation key as the tab's tooltip, so the title and the tab that is marked
active cannot drift apart. Only the Admin Panel has one; a page whose name is
the whole answer gets no slash, and neither does a board, whose own title IS
its name.
A sidebar button is no longer drawn under the close button. `.sidebar-xmark`
is `position: absolute`, so it contributes no height and the row holding it
collapsed to its padding - on a board that row also holds the keyboard
shortcut buttons and so has a height anyway, which is why only the All Boards
and shared page sidebars showed it.
Two mistakes here were reverted rather than patched, because patching them
would have left the damage in the file. A regex rewriting `header.css`
matched inside `min-height` and produced `min-min-height`; and a slice of
`boardHeader.jade` ran backwards, deleting every template between
`boardVisibilityList` and `boardViewMenu` - that is what
`Template.boardChangeTitlePopup is undefined` and `Template
"boardVisibilityList" is set twice` were. Both files were restored from git
and redone with exact replacements.
Guards in tests/headerBars.test.cjs pin all six: that no rule on the bar
forces one row, clips, or fixes a height; that the end group is pushed with a
logical margin and never a physical one; that each view menu names its view
through a real translation key; that the active mark is darker than the hover
and survives being hovered; that the Admin Panel's second segment shares its
key with the tab; and that both sidebars reserve the close button's height.
The wrap guard reads the DECLARATIONS, not the comments - the rules that
replaced these explain themselves by naming `nowrap` and `overflow: hidden`,
and a guard that greps the whole body reads its own explanation.
Thanks to xet7 !
set-selected-unstarred ("Unstar the selected boards") and multi-selection-off
("Turn Multi-Selection off") were added with this session's All Boards work and
went into every language file as English placeholders, which is what the
translation policy does with a string untranslated everywhere. 90 languages were
filled in the first pass; these are the rest that I can write with confidence -
Welsh, Esperanto, Frisian, Valencian, Asturian, Azerbaijani, Armenian, Georgian,
Khmer, Mongolian, Tamil, Telugu, Punjabi, Gujarati, Odia, Uyghur, Uzbek, Xhosa,
Zulu, Yoruba, Breton, Occitan, Walloon, Yiddish, Wolof, Igbo, and the Chinese
variants, with their regional tags.
131 of 142 languages have both strings now.
Each is written from that language's OWN existing translations rather than from
English: the sibling key set-selected-starred is already translated in every one
of these files, and click-to-unstar and multi-selection-on gave the verb and the
noun each language actually uses for starring and for multi-selection.
Eleven are deliberately left as English placeholders - ace, ary, ja-HI, tk_TM,
tlh, ve, ve-CC, ve-PP, vl-SS, vo, zgh. I cannot write those to a standard I
would stand behind, and the policy is explicit that the English source standing
in is the honest state for a string nobody has translated. A guess dressed as a
translation is worse than a placeholder, because nothing marks it as a guess.
Applied with fill-translations.mjs --apply, which writes ONLY into keys that are
still English placeholders, so it cannot overwrite a human translation.
verify-human-preference.mjs passes 10/10, the diff touches these two keys and
nothing else, and every file still holds the same 2333 keys. Nothing is pushed
to Transifex: a filled string must never be able to masquerade as human there.
Thanks to xet7 !
Three things, as asked.
NO DUPLICATE BOARD NAME. The second bar drew the board's title in an `h1` of its
own, under the same name in the bar above it. The first bar has said it since
the first step of this work; the h1 is gone.
THE PENCIL IS BESIDE THE NAME. Renaming a board belongs next to the board's
name, not in a bar of its own, so `boardEditTitleButton` renders immediately
right of the title in the first bar. Board admins only, as before.
EVERYTHING ELSE IS AN ICON, LEFT OF THE BELL. Visibility, watch, star, sort,
the board's rule buttons, filter, search, dependencies and Multi-Selection, each
named by a tooltip and none carrying a visible label. The bar is one row shared
with the logo, the starred boards, the view menu, the bell and the user menu;
ten labelled buttons do not fit at any width.
The desktop/mobile duplication went with it. The old bar wrote the whole set out
TWICE - `unless isMiniScreen` and `if isMiniScreen` - because a row of labelled
buttons does not fit on a phone. Icons do, so one copy serves both, and the two
switches that had to be kept in step are gone rather than kept in step.
headerBarControls.jade is deleted. It held one Search button and one
Multi-Selection button, written for this bar and All Boards'; both bars are gone
now, so a template shared by nobody was indirection rather than sharing.
Every route reference goes with the bar: config/router.js names no headerBar at
all any more.
Six guards followed, and four of them existed only to measure this bar: the
title-plus-buttons row that a long board title split across two lines, the
wrapper that fixed it, the phone layout, and the hamburger that shared the first
row with the title. All of that was one problem, and the problem is gone with
the row. Each says so where it used to measure it, rather than being deleted.
Thanks to xet7 !
Two things the screenshot showed, both consequences of emptying the second bar
that I had not followed through on.
A TALL EMPTY BLUE STRIP under the first bar, on every page. `#header` is a
coloured block with a height of its own, and most pages have no second bar any
more - their title is in the bar above and their controls are in a sidebar - so
it painted an empty band wherever there was nothing to put in it. It renders
only where a page still has a header bar now.
CRAMPED BARE GLYPHS in the first bar. Every rule that styles a
`.board-header-btn` was scoped to `#header-main-bar`, which is the SECOND bar -
so the page's view menu, the starred-boards dropdown, the Admin Panel tabs and
the sidebar hamburger, all of which moved UP into the first bar, had no padding,
no height and no line-height and ran together into one another. The first bar
states the same look for its own buttons, with its own divider.
That is the same mistake as the All Boards search box, which kept matching
`.boards-path-header .board-search` after it had moved: a control that moves
between containers takes its stylesheet with it or stops being styled at all,
and nothing says so until somebody looks.
Thanks to xet7 !
The first header bar names three pages that carry a name of their own - Support
and Accessibility can be renamed by an admin, and Import names the source it is
importing from - and it asked for those names by importing each page's own
module.
The header is rendered by the layout, so it loads very early. Importing a page's
module pulls that module into the header's graph and RUNS it, before its .jade
has registered. `Template.import.onCreated` was `undefined.onCreated`: a
TypeError at module load. A module that throws aborts every module after it, so
the visible symptom was not that one page - it was "No such template:
connectionMethod" and a string of others that never got to register at all.
The three titles are in client/lib/pageTitleSources.js now, which registers no
template and touches no DOM, so it is safe to load at any point. The header asks
that; the pages use the same one rather than each keeping a copy.
A guard asserts the header imports nothing from client/components, that the
module it does import registers no template, and that neither page defines a
second copy of its own title. It reads the CODE and not the comments - the
module explains the bug by naming Template.import.onCreated, and the first
version of the guard failed on its own explanation.
Thanks to xet7 !
The first bar listed every starred board inline. That is the widest thing in the
bar, and it grows with the number of boards you star, so the bar could not stay
one row - which is what it has to be now that the page title, the view menu, the
Admin Panel tabs and the sidebar hamburger are all in it.
It is one button: a caret, then a star, laid out exactly like the view menu
beside it - caret first, then what the button is about - with the number of
starred boards on it the way a board's own star shows its count. The names are
in the popup it opens, shaped like boardChangeViewPopup because it is the same
kind of menu opened from the same kind of button: a pop-over list of links, with
a check on the board you are looking at, and something to say when none are
starred.
On a phone it is full width. Nothing here asks for that: body.mobile-mode
already makes every pop-over full screen, so using the ordinary popup was enough
- and the guard says so, rather than leaving the next reader to wonder where it
comes from.
One thing is deliberately NOT folded in: on a phone, inside a list, the bar
still shows that board's LISTS. That is a different thing from the starred
boards - it is what tells you where you are while you swipe between lists - so
it stays as it was.
Thanks to xet7 !
You asked me to check where the view dropdowns are. They were in two different
wrong places, and neither was the one that makes sense.
A view menu says what you are looking AT. That belongs beside the page's name,
in the bar that is always on screen - not in a second header bar, which is where
the board's Swimlanes / Lists / Calendar / Gantt / Table / Stats menu still was,
and not behind a panel you have to open first, which is where I had put the All
Boards Lists / Table menu one step ago. Both are in the first bar now, one or
the other by page, left of the notification bell beside the Admin Panel tabs.
Each is its own template with its own handler, because a Blaze event map only
sees events inside its own template. The board's keeps its `unless
currentBoard.isTemplatesBoard` - there is nothing to look at six ways on a
templates board - and the All Boards one still names the CURRENT view rather
than itself, the way the board header says "Swimlanes" or "Lists" and never
"Board View".
Public is deliberately not in the list of All Boards routes here: it has no
Lists/Table choice to offer.
Three guards followed the menus, and a new one pins where they are, that each is
handled where it is drawn, and that neither is left behind in the place it came
from.
Remaining: the board's own second header bar, which is now one control lighter.
Thanks to xet7 !
Fifth of the header restructure, and the answer to the question I had left open:
the panel's tabs are navigation BETWEEN its four pages rather than controls of
one, so they do not belong in a right sidebar with a page's controls. They
belong where you can always see which of the four you are on - the first header
bar, immediately left of the notification bell.
Icon only, each named by a translated tooltip, because that bar is a single row
shared with the logo, the starred boards, the notification bell and the user
menu; four labelled buttons would not fit beside them. They link by route NAME,
as they did.
The tab bar goes with them, and its four route references. "Admin Panel" is
already what the first bar says on all four routes - models/lib/pageTitles.js
has mapped them since the first step - so the panel is named once, there, rather
than by an h1 of its own.
Which routes ARE the Admin Panel is read from the one place that already lists
them, adminUrls.js, rather than spelled out again in the header.
Three guards followed. The same mistake as last time was caught by the same
guard: settingHeader.js still registered onCreated, onDestroyed and helpers on a
template that no longer existed, which throws at module load and aborts every
module after it - they are on adminPanelTabs now. The security-log guard checked
for a `.setting-header-btn.problems`, and the multitenancy guard checked that
Settings and Problems sit behind the site-admin check; both properties are
unchanged and both now read the markup where it is.
Remaining: the board's own second header bar, which is the last one with
controls in it.
Thanks to xet7 !
Fourth of the header restructure. This page had the last second header bar with
controls in it, and it now has none: the first bar names the page, and every
control is a row of the right sidebar the page already had.
Only two things actually needed moving. Sort and the Lists/Table view menu
become sidebar rows, keeping what made them worth looking at - Sort still shows
emphasis while a sort other than the custom order is on, and the view row still
names the CURRENT view rather than itself. Search and Multi-Selection did NOT
move: the sidebar's home view has offered both as rows since it was written, so
carrying the header buttons across would have listed each of them twice.
The handlers moved with their markup, because a Blaze event map only sees events
inside its own template, and the bar's helpers went with the bar.
One consequence worth naming: headerSearchButton and
headerMultiSelectionButton were written for TWO bars, and there is one left. The
board header is the only user now. They stay shared rather than being folded
back into it, because the board's bar is the next one to be emptied - but the
guards say plainly that the sharing has one side today, instead of asserting a
parity that no longer exists.
Six guards followed. The control-order test has now followed these controls
three times - `.path-right`, then the header bar, then nowhere - and says so;
the Public page's "do not print the title twice" test names the FIRST header bar
as what prints it; and the All Boards tests read the sidebar where they used to
read the bar.
Remaining: the board's own second bar, and the Admin Panel's tab bar.
Thanks to xet7 !
Third of the header restructure, and the simplest: Support, Shortcuts,
Accessibility, Broken Cards, Import and Boards in Archive had nothing in their
second header bar but the page's name, which the first bar carries now. The bar
and the route reference go with it, so the page is its content and nothing else.
Three of them do not have a FIXED name, and dropping the bar would have lost it:
an admin can rename Support and Accessibility, and Import names the source it is
importing from ("Import / Trello"). Each of the three exports its title, and the
top header bar asks for it - a custom title beats the key and is printed as
text, exactly like a board's. So the title rule is three sources in order: the
board's, then a custom one, then the key.
Shortcuts and Import also had a back arrow to All Boards. The house icon in the
first bar is that link, on every page, so it went with the bar rather than being
carried over as a second one.
The repo's own guard caught the mistake in this: support.js and accessibility.js
still registered onCreated and helpers on templates that no longer existed,
which throws at module load and aborts every module after it. The registrations
are gone; the SUBSCRIPTION each one made is not, because the top header bar
reads the same setting to name the page - it just belongs to the page's own
template now.
Two guards updated: the archive page is named by the top bar rather than by one
of its own (its title has moved three times, and each move left the previous one
to remove or the name is printed twice), and the page-sidebar guard now covers
all ten removed bars rather than the first four.
Remaining: the board's own second bar, All Boards' own, and the Admin Panel's
tab bar.
Thanks to xet7 !
Second of the header restructure. The title moved to the first bar last time;
the buttons move here, so a page is its content plus one panel rather than its
content plus a strip of buttons above it.
Four pages had controls in their second header bar: My Cards, Due Cards, Global
Search and Rules. Each group becomes a <page>Controls template, and the shared
page sidebar renders it. Their header bars are gone entirely - title moved,
controls moved, nothing left to hold - and the routes no longer name them, which
they must not: a route naming a template that does not exist renders "No such
template" in that bar.
The handlers followed their markup. A Blaze event map only sees events inside
its OWN template, so Template.dueCardsHeaderBar.events would have fired for
nothing once the buttons were somewhere else.
The sidebar itself is the board sidebar's shell again - the same classes, the
same close button, the same content area - with the two things the All Boards
one had to learn: the theme goes on a WRAPPER, because every themed sidebar rule
is a descendant selector and a class on `.sidebar` matches nothing; and it is
pinned to the viewport below the measured header height, sharing that rule with
the All Boards panel rather than restating it.
Which sidebar the hamburger opens now depends on where you are: a board's own, All
Boards' own, or this shared one. And it is only drawn where it opens something -
a page with no controls has no sidebar and no hamburger, because a panel that
opens empty is worse than no panel.
One guard needed correcting: the All Boards geometry test read a single
selector, and that rule is a selector list now.
What is still NOT done: the board's own second bar and All Boards' own, whose
controls have a sidebar to move into but have not moved yet, and the Admin
Panel's tab bar. The pages that were only a title - Support, Shortcuts,
Accessibility, Broken Cards, Import, Archive - keep their second bar for now;
they have nothing to move, only a title to drop.
Thanks to xet7 !
First of the header restructure. Three things, all in the bar that is always on
screen.
ONE COLOUR. Each theme painted the two bars two shades apart, so the header read
as two headers stacked. They are one colour now, and per theme it is stated
once: the quick-access bar joins the selector list the main bar already has,
rather than restating each hex a second time. moderndark said it its own way and
was the one theme where the two really differed (#2a2a2a against #333); it is on
the main bar's colour like the rest.
THE PAGE'S NAME, not "All Boards". The first bar said "All Boards" beside its
house icon on every page, whatever page it was - so the bar that is always
visible named a place you were not. The house is still the link home, with that
name as its tooltip; the text beside it is where you ARE, and on a board that is
the board's title. models/lib/pageTitles.js maps a route to a title key, which
is one answer to "what is this page called" in place of the thirteen `h1`s that
each answer it separately. A board title is printed as TEXT and a page title
goes through the translator: a board called "settings" is not the Admin Panel.
ONE HAMBURGER, in this bar, to the right of the username, with the divider that
came with it. It was the last item of the SECOND bar - the bar whose contents
are moving into the sidebar it opens. On a board it toggles the board's own
sidebar; on every other page the shared one.
Two guards had to be corrected, and both were wrong before this change rather
than because of it. The theme-accent guard derived each theme's accent by
matching `.board-color-X#header`, which also matches `#header-quick-access`, so
for clearblue it had recorded the quick-access shade (#2d8ce7) as the accent
instead of the main bar's (#00aecc); it matches `#header` exactly now. And the
new guard first checked that the two bars share a selector LIST, which is a
spelling rather than the property - it compares the colours they resolve to.
What is NOT in this commit: emptying each page's second header bar into a right
sidebar. That is thirteen templates and a shared page sidebar for the pages that
have none, and it is the next step.
Thanks to xet7 !
The theme class was on the `.sidebar` element itself. Every themed sidebar rule
is a DESCENDANT selector -
.board-color-belize .sidebar .sidebar-content .sidebar-btn { ... }
- so a class on that same element matches nothing at all, and the panel stayed
exactly as it was: white icons and white text on a light grey background. On a
board the class sits on a CONTAINER around the sidebar, which is what makes
those rules apply, and that is what was missing here.
.all-boards-sidebar-theme is that container. It wraps only the sidebar, so the
theme cannot bleed into the board icons behind it, and it is out of flow - the
sidebar inside is fixed - so it costs no layout. Boards in Archive, and every
other .sidebar-btn in the panel, now gets the themed background with its white
icon and text on top of it, and the list rows get the themed hover.
The guard passed on the broken version, which is why it shipped: it asked
whether the theme class was somewhere in the file, not whether it was on an
ancestor of the sidebar. It now asserts both halves - that the sidebar element
does NOT carry it, and that a wrapper containing the sidebar does. Verified it
fails on the markup as it was.
Thanks to xet7 !
It was Modal.open('archivedBoards'), from three different menus, so the one
place that lists every archived board - with its own search and its own pager -
was a box floating over whatever you happened to be looking at. It had no
address, so it could not be linked to a colleague, bookmarked or opened in a
second tab; there was nothing to come back to; and Escape closed it while you
were reading it. "Restore the board I archived last month" is a task, not a
glance.
/archive is a page now, in the default layout, with the same second top header
bar as every other page and the board session state cleared the way the other
non-board pages clear it. The template is the same one the modal used, minus its
own h2: a modal has no header bar to be named in, so it drew its own title, and
on a page that is the title printed twice. archivedBoardsHeaderBar names it
once, where every other page is named. The search, the per-board Restore and
Delete, and the SERVER-side paging all survive the move - the subscription still
publishes one page at a time, so a long archive stays fast.
All four entry points go to it by route NAME rather than by a path spelled out
in four places: the board menu, the member menu, the board sidebar, and the All
Boards sidebar.
That fourth one did not work at all. The row was drawn in the All Boards
sidebar's home view, and clicking it did nothing: the handler it relied on lived
in the header bar's events map and went when that map was rewritten for the
sidebar. A Blaze event map only sees events inside its own template, and the
button had moved out of it. It has its own handler now, and the sidebar closes
behind it - the page it opens replaces the page the panel belongs to.
Written down in docs/Design/Page/Archive.md.
Thanks to xet7 !
Three things xet7 reported with a screenshot, all of them mine, all of them in
the shell the sidebar borrows from a board.
It was WHITE TEXT ON LIGHT GREY. .sidebar .sidebar-content .sidebar-btn is a
light grey box whose text is white; what makes that readable on a board is a
.board-color-* ancestor replacing the grey with a themed colour. This page has
no board, so nothing did. The shell carries board-color-belize now, the
established default for a themed thing outside a board - globalSearch.js falls
back to the same one. It is a class on the element, so the theme is the same on
a phone and on a desktop; only the geometry below is desktop-only.
It FLOATED OVER THE BOARD ICONS, starting halfway down the page and ending as
soon as its content did. It had inherited position: absolute, which resolves
against the nearest positioned ancestor: on a board that is the board container,
which already starts below the header and runs to the bottom of the window, and
this page has no such container. It is pinned to the viewport now - top is
var(--wekan-header-height), the height the header MEASURES itself to be (it is
not a constant: its buttons wrap to one, two or three rows depending on language
and window width, and utils.js keeps the variable current with a ResizeObserver),
and bottom is the window. Below the second top header bar, full height, like the
board's.
And the boards now move LEFT instead of being covered:
.all-boards-wrapper.sidebar-open .boards-layout gives the panel its 420px back -
the width the sidebar is - so the icons reflow into what is left between the menu
and the panel and every one stays visible.
Desktop only, both of those. On a phone .board-sidebar.sidebar in the board
sidebar's own stylesheet already pins this element - it carries that class - to
the full screen width below the header, and there is nothing to move aside from
a full-width panel.
All three views share the shell, so Search and the hamburger's home menu are
fixed by the same change, not only Multi-Selection.
Thanks to xet7 !
v10.53 built the amd64 snap, the arm64 snap and both variant snaps - forty
minutes of runner time across four jobs - and then every one of them died at
the upload with the same red X, because the credential was not usable. Nothing
before that point had asked the store anything: the per-job check only tested
that the secret is non-empty and longer than 100 characters, which a revoked or
long-expired credential also is.
snapcraft whoami is the question, asked once, in a job that snap-native,
snap-launchpad and snap-variants all wait for. It costs about a runner-minute,
it answers from the store itself, and its answer distinguishes the causes that
a failure at upload time cannot:
cannot be parsed the secret is not an exported login at all - mangled,
truncated, or the terminal OUTPUT of export-login instead
of the file it wrote. The store was never asked.
expired it WAS valid. An exported login lasts one year by default,
so a credential that worked for a year stops without
anything having changed. This is the cause the upload-time
message never mentioned, and the likeliest one when every
arch fails identically.
401 / 403 readable, but without the ACL for this snap name.
It also checks the credential covers all three snap names this release
publishes. One exported for `wekan` alone lets snap-native succeed and
snap-variants fail, which reads as two unrelated bugs; `packages: no
restrictions` covers everything and passes.
The credential is never echoed - only what the store said about it. The whoami
call runs with `set +e` on purpose: `set -e` would kill the step at the failure,
before the case statement that says which failure it was.
build-sandstorm publishes no snap, so it does NOT wait for this - the first
version of the change made it wait, because it happened to have the same needs
line.
Thanks to xet7 !
Two entries for the two commits that gave every left-menu entry an address:
the Admin Panel's /settings/visibility, /people/roles, /admin-reports/cpu, and
All Boards' /allboards/starred and /allboards/workspaces/<name>/<sub-name>.
Thanks to xet7 !
The page was three addresses - / (Starred), /templates and /remaining - and the
workspaces tree had none at all. Which workspace you had open was a ReactiveVar,
so a workspace could not be linked to a colleague, bookmarked, opened in a
second tab or reached with the back button.
/allboards/starred
/allboards/templates
/allboards/remaining
/allboards/workspaces
/allboards/workspaces/engineering/front-end/design-system
The route is '/allboards/:section?/:path*'. :path* is zero or more SEGMENTS -
[^/]+ would stop at the first slash and a workspace two levels down would not
resolve - so a workspace nests as deep as its tree does. / stays the home and
still shows Starred; /templates and /remaining redirect to the new form.
A workspace is addressed by the slugs of its NAMES down the tree, not by its id:
the id is a random string and a URL should say where you are. The slugs come
from getSlug (limax), the same function that gives a board its slug in
models/boards.js, so a workspace and a board turn a name into a URL the same
way, including the scripts where a naive slugifier returns an empty string. When
a name slugifies to nothing anyway - an emoji-only name - the node's id stands
in, because a workspace with no address could not be opened from a link.
The route resolves the section and splits the wildcard; the PAGE resolves the
workspace, in an autorun, once its tree has loaded. The router cannot: the tree
is on the user document, which it has no way to read before the page has it. And
it has to be an autorun rather than a one-shot read, which would always run
before the tree arrived and never select anything. A slug path that names
nothing leaves the Workspaces section selected rather than an empty list.
models/lib/allBoardsUrls.js takes slugify as an ARGUMENT rather than importing
it, so it has no dependencies at all and is unit-tested with a stub - and with
the real limax, for the tests that are about the real one.
One thing this had to fix rather than add: boardsList.js filters boards by
membership only on the All Boards routes, by route NAME, and a route missing
from that list falls through to the public-boards branch - the page would have
shown public boards instead of the user's own. The new name is in the list, and
the guard pins all four.
Written down in docs/Design/Page/All-Boards-URLs.md.
Thanks to xet7 !
The panel was four addresses - /setting, /people, /admin-reports, /attachments -
each opening whichever pane its page happened to open first. Which pane you were
looking at was ReactiveVar state and nothing else, so a pane could not be linked
to a colleague, bookmarked, opened in a second tab or reached with the back
button, and /setting always landed on Version even if you had just been in
Global Webhooks.
Every entry has its own now: /settings/visibility, /settings/global-webhooks,
/people/roles, /admin-reports/cpu, /attachments/s3. Lowercase, words separated
by -, and the DEFAULT pane keeps the bare page URL - /settings, not
/settings/version - so there is one address for "the Settings page" rather than
two that show the same thing. The Settings path is plural: /setting was the odd
one out beside /people, /attachments and /admin-reports, and it still resolves,
as a redirect.
The slug is NOT derived from the pane id. The ids are internal and read like it
- tableVisibilityMode-setting, layout-setting, report-cpu - while a URL is
something a person types and pastes into a chat, and a name derived from another
name is wrong the moment the two spellings differ, which is what
allBoardsMultiselectionSidebar was. So models/lib/adminUrls.js is an explicit
map, and the guard checks it against the real menus in BOTH directions: every
slug names a pane the page has, and every menu entry has a slug. Neither failure
shows up until somebody clicks that row. A slug that is not one falls back to
the page's default rather than rendering an empty panel, because a URL is typed.
The route resolves the slug and hands the pane id to the page, which reads it in
an autorun - reactive, not a one-shot read, because following a link to another
pane while the page is open runs the route action again WITHOUT re-creating the
template. A menu click does the same in reverse: open the pane, then go to its
URL. go, not replace, so Back returns to the previous pane instead of leaving
the Admin Panel, and only when the path would actually change. Each page now has
ONE function that opens a pane by id, used by both the URL and the click.
/information and /translation redirected to /setting and handed their pane over
in a Session value the page consumed once. They redirect to the pane's own
address now.
Two guards pinned that Session mechanism and now pin the URLs, each saying why
it changed. Writing the map turned up that the Settings page's pane list still
named registration-setting, the Login pane that moved to People - which its own
guard caught, correctly.
Written down in docs/Design/Page/Admin-Panel-URLs.md, with every URL listed.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 27 links in the Upcoming section resolve.
Thanks to xet7 !
All Boards had a search FIELD in its header bar, and a Multi-Selection button
whose actions were four icons beside it. A board has a search BUTTON and a
Multi-Selection button, and both open the right sidebar. Two pages, the same two
control names in the same place, behaving differently - and one of them not the
way the rest of WeKan does. They are the board's now, and they are the same
markup: headerBarControls.jade holds one headerSearchButton and one
headerMultiSelectionButton, included by the board header of the Swimlanes view
and by the All Boards bar. The two copies had already drifted - the close button
that turns Multi-Selection off said "Clear filter" on the board header, which is
what a different control does, and says multi-selection-off in both now.
Only the markup is shared. What a click does is not - a board searches and
selects cards, All Boards searches and selects boards - and it does not need to
be: a Blaze event map catches events from the templates rendered inside it, so
each bar's own map sees the clicks on its own copy. isActive is passed in,
because the two pages keep different selection objects.
All Boards has a right sidebar of its own. Not the board one: that is built
around a board's members, labels, activities and settings, and this page has no
board. It borrows the SHELL - the same .board-sidebar.sidebar classes, the same
close button, the same title-and-back-arrow above a view - so the two look and
behave alike, and it has three views. home, what the hamburger opens, is the
page's menu: Search, Multi-Selection, and Boards in Archive, which had a handler
in the header bar and no way to reach it. search is the field that was in the
bar, still writing the page's own search term so the boards behind it narrow as
you type. multiselection is where the actions on a selection went - star, home,
archive, duplicate and a way out - each with its name beside it instead of being
a crowded icon. Open/closed and which view are separate, so closing does not
forget where you were, and Escape closes it by KEY only: the click half of
EscapeActions would close it on every click, including clicks inside it.
The bar also gained the divider and the hamburger, in their own flex item and
last in the source exactly as the board header has them, which is what keeps the
hamburger in the top right on a phone while the other buttons wrap.
Two things this turned up. A .jade file is not picked up by being on disk - it
has to be imported from client/features/, and the shared controls threw "No such
template: headerSearchButton" on render until they were; a guard now requires
every .jade under client/components to be imported and every +template it
includes to exist, and it found a dangling +subtaskDeleteDialog that has never
had a template (unreachable, so it has never thrown, and it is recorded with
that reason rather than hidden). And deriving a template name from a view name
gave allBoardsMultiselectionSidebar for allBoardsMultiSelectionSidebar - one
letter, renders nothing, no error worth the name - so the names are an explicit
map the guard checks.
Written down in docs/Design/Page/Search.md and Multi-Selection.md, one per
shared control, each covering both pages, with All-Boards.md pointing at them.
Four guards pinned the previous shape and now pin this one, each saying why it
changed: the search field in the bar, the four selection buttons in the bar, the
Multi-Selection parity check that is structural now that there is one template,
and the home-board action that moved to the sidebar.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 26 links in the Upcoming section resolve.
Thanks to xet7 !
The "Selected: star" button only ever ADDED stars: it walked the selection and
starred whatever was not starred yet. Once every selected board was starred it
did nothing at all, so there was no way to undo from here what it had just
done, and its tooltip read "Star the selected boards" whatever state the
selection was in. It is a toggle now - none starred stars them all, all starred
unstars them all, and a mixed selection stars the rest, leaving the starred
alone. The mixed case deliberately does not flip each board independently: one
click that starred some boards and un-starred others is not something a button
may do. Only the boards that must change are called, because toggleBoardStar
flips one board and calling it for an already-starred one would un-star it.
The tooltip says which way the button goes right now, set-selected-starred or
the new set-selected-unstarred, and it reads the SAME function the click does -
two copies of "which way does this button go" would eventually be two answers,
and the tooltip would lie about what the click does. The rule itself is
models/lib/selectedStars.js: pure, so it is unit-tested rather than reasoned
about, including the mixed case and an empty or junk selection.
All four buttons on a selection are icon-only now, and they follow the
"Selected:" label in the order star, home, archive, duplicate - the two that
only mark a board first, the two that change what boards exist last. Archive
and duplicate carried their names, which are sentences and pushed the bar onto
a second row.
Checking that each button says what it is turned up one that did not: the close
button that turns Multi-Selection off said "Clear filter", which is what the
OTHER one in this bar does, in the search box. It is multi-selection-off now. A
guard requires every .board-header-btn in the bar to carry a title, and every
title to come out of a translation key rather than being literal English.
Both new keys are in all 147 language files as English placeholders, which is
what the translation policy does with a string untranslated everywhere, so no
language silently loses a tooltip. The seven language files that are symlinks
share their target's copy rather than getting a second one, and the 43 files
that have no trailing newline still have none.
The Font Awesome guard flagged a character in a jade COMMENT, which renders
nothing - the same exemption it already makes for arrows and box drawing. It
skips comment blocks now, tracked by indentation so a continuation line is
skipped too, with a self-check that a rendered line is still caught.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 25 links in the Upcoming section resolve.
Thanks to xet7 !
Starred was the first button in the header bar, and Starred is a section: the
left menu already lists it beside Templates and Remaining, counts it, and
highlights it when it is the one shown. A second way to reach one section, one
click away from the first, is a control whose only job is to be kept in step
with the menu - so it is gone, and with it the header bar's own js-select-menu
handler and isSelectedMenu helper, which had no other caller there. The left
menu is part of boardList and has always had its own.
Multi-Selection moved to the right of the Lists/Table menu. The bar now reads
left to right as what is shown - Sort, Search, Lists/Table - and then what is
selected: Multi-Selection, followed by the archive, duplicate, star and home
actions that appear with it.
The search box is 150px wide, half of the 300px it was drawn at when it had a
card of its own. Getting there turned up that it had no styling at all: its
rules said .boards-path-header .board-search, the bar it used to live in, so
from the moment the controls moved to the header bar they matched nothing and
the box rendered at the browser's default input size. They are
.all-boards-controls now - and because a white box on a themed bar cannot
inherit that bar's light-on-dark colour without putting white text in a white
box, the input, the magnifier and the clear button each set their own.
The two guards that pinned the old order now pin this one and say why. Both of
them, and the removed-bar guard beside them, had started reading the comments
of the rules they were checking - the new rules explain themselves by naming
the selector they replaced - so they strip comments before asserting.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 24 links in the Upcoming section resolve.
Thanks to xet7 !
Once every control had moved to the header bar, what was left above "+ Add
Board" was a white strip carrying one thing: a Font Awesome icon for whichever
left-menu section is selected. Three things already say which section that is -
the left menu highlights it, the header bar names the page, and the Starred
control is emphasised while Starred is on - so the strip said nothing and cost
a bar's worth of height on every screen. The board icons start at the top of
the right column now.
It took more with it than the markup. currentMenuPath, sixty-four lines that
resolved a workspace path to an icon and a name, had no other caller. Every
.boards-path-header rule went - the seventeen that styled the bar and its
contents, and the phone media query that had already been reduced to holding
the title. So did the pulse keyframes, whose only user was the multi-selection
hint that lived in the bar. The pager's flex: 0 0 auto was sharing a selector
list with the bar, and is kept on its own: it is still the fixed-height thing
above the scrolling list. Verified the stylesheet's braces still balance and
that it has no empty at-rule left.
Two guards named the bar and now assert it is gone, each saying why: the
scroll-chain guard asked which element above the list keeps its own height,
and the tile-height guard asked whether the bar wraps.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 23 links in the Upcoming section resolve.
Thanks to xet7 !
"Install WeKan dependencies" ran brew install node@24, which gives whatever
24.x Homebrew currently has bottled rather than what nodejs.org has released,
and which is keg-only - so the branch also wrote PATH, LDFLAGS and CPPFLAGS for
it into ~/.zshrc by hand. It is nvm install 24 now: the major alone, so it
resolves to the newest 24.x every time it runs and never needs bumping, and nvm
puts it on PATH itself. npm comes with the Node it installs, so brew install
npm is gone too.
The nvm installer is fetched from a pinned release tag rather than master,
because the line pipes a downloaded script into a shell, and nvm.sh is sourced
before the first nvm call - nvm is a shell function, not a binary, so without
that every call is "command not found" even straight after a successful
install. An NVM_DIR the caller has already set is honoured, and a failed
install is reported instead of run into.
Two things the old branch did are now actively undone. npm config set prefix
'~/.npm' cannot coexist with nvm: it overrides the per-version prefix, global
installs land outside the Node they were installed for, and nvm refuses to
switch versions while it is set - so the branch clears it, which a machine that
ran the Homebrew path needs. And that prefix directory was created with a
quoted tilde, mkdir "~/.npm", which makes a directory literally named ~ in
whatever directory build.sh was run from.
Linux is untouched and still installs Node with n, which the new guard pins so
the split stays deliberate. Verified the guard fails on the branch as it was -
seven of its nine tests - and passes on this one, and that build.sh still
parses.
Thanks to xet7 !
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. fix-changelog-hashes.sh found the rewritten copy
by subject and author date. All 22 links in the Upcoming section resolve.
Thanks to xet7 !
The header bar took the controls of the page, but the actions ON a selection -
Move Board to Archive, Duplicate Board, and the "Selected:" star and home -
were left where they were, in a strip above the board icons, on the reasoning
that they are about those boards rather than about the page. That still left
two places to look for a button. They are in the header bar now, to the right
of the controls that are always there, as .board-header-btn like every control
beside them, and .boards-path-header holds the section title and nothing else.
They appear only while something is selected: four buttons that would do
nothing are worse than no buttons. Archive and duplicate carry their names;
star and home stay icon-only under the "Selected:" label that already named
them, because their names are sentences - "Set as Home board (opened after
login)" - that belong in a tooltip, and spelling them out pushes the bar onto
a second row. The label stays a label, with neither the button class nor any
button behaviour.
Two things had to move with the buttons, because Blaze binds both to a single
template: their four click handlers, which an event map on boardList could no
longer see, and hasBoardsSelected, which decides whether they are drawn. The
stylesheet lost .path-right, .selected-action and .selected-actions, which now
select nothing, and the phone media query lost the four rules that flattened a
controls row this page no longer has.
Two guards pinned the previous decision and now pin this one, each saying why
it changed: the All Boards guard asserted the selection actions stay beside
the boards, and the tile-height guard asserted that .path-right wraps - which
the header bar's own button group does instead.
Thanks to xet7 !
The entry was written before the commit it describes existed, so it carried a
placeholder that the amend then made stale.
fix-changelog-hashes.sh found the rewritten copy by subject and author date.
All 21 links in the Upcoming section resolve.
Thanks to xet7 !
The page chooses between the board icons and the Table view with
{{#if isAllBoardsView 'table'}}, and that helper was registered on
boardListHeaderBar and on allBoardsViewPopup but not on boardList - the
template that actually asks. A Blaze helper belongs to the template it is
registered on, so the page threw as soon as the router rendered it, right
after login, and All Boards did not come up at all.
The guard did not notice because it read the jade and the JavaScript as two
separate files: it checked that the controls are in the header bar and that
the Table branch calls +tablePage, never that the template asking a question
has the helper that answers it. It now collects every helper this file
registers, and for each template in the jade every helper it uses that this
file defines must be registered on THAT template. Only helpers the file itself
defines are checked - a name it registers nowhere is a model helper on the
data context, like colorClass on a board, and a guard cannot tell one of those
from a typo. The other four templates in the file were clean.
Verified the new test fails on the code as it was and passes on the fix.
Thanks to xet7 !
Updated dependencies. was the twentieth commit since v10.53 and the only one
without a Changelog entry: @meteorjs/rspack 2.0.1 -> 2.1.0-beta.0, the bundler
half of the Meteor 3.5.1-beta.0 upgrade beside it. Its rimraf 6 brings glob 13,
which no longer ships a command line, so twenty-seven packages leave the
lockfile with it - 975 to 948 - and nothing is dropped from WeKan itself.
Checked the whole Upcoming section against the rules in CLAUDE.md: one summary
was 115 characters against the 110 it aims for, and is shorter now. Everything
else holds - one # heading, 80-column wrap outside links, every summary on one
line with its own commit link and no markup in it, no hash or URL as link
text, blank lines around every block, the subsections reading as one sentence,
and the closing Thanks to above GitHub users line. All twenty commit links
resolve in this clone.
Thanks to xet7 !
The Upcoming section listed twelve of the nineteen commits made since v10.53,
and put eight of them under "and documents the following:", which described
one of them. It is now the order CLAUDE.md asks for - new features, the user
interface, dependencies, bug fixes, documentation, translations - and it holds
the seven that were missing: the All Boards header bar and Table view, the two
fixes that followed the Public Boards page, the Roles Status translations, the
newest Transifex pull, Meteor 3.5.1-beta.0 and the meteor-node-stubs fork. The
section ends with the "Thanks to above GitHub users" line it was missing.
All nineteen commit links resolve in this clone.
Thanks to xet7 !
Design: docs/Design/Page/All-Boards.md, whose Table view is the shared table page
(docs/Design/Page/Table.md) and which describes only what is different here.
ONE ROW OF CONTROLS. The page carried its own row above the board icons -
Multi-Selection with its archive and duplicate actions, Sort, the search box - in
.boards-path-header, while the second top header bar above it held only the title.
Two rows of controls on one page, one of them styled like the board header of the
Swimlanes view and one not. Starred, Sort, Search, Multi-Selection and the view
menu are in the header bar now, as .board-header-btn with the same Font Awesome
glyphs and the same emphasis state, so this page agrees with every other page
about where a WeKan page puts its controls.
The actions ON a selection stayed with the boards: Archive, Duplicate and the
"Selected:" star/home actions appear only while something is selected and are
about those rows, not about the page.
SEARCH IS A FIELD, not a button that opens a search view. It filters as you type,
searches across all your boards ignoring the selected section while it has text,
and Escape clears it - the old right-pane behaviour exactly. That is a deliberate
difference from the board header, whose Search opens a search view over cards;
here it filters the list it sits above.
THE VIEW MENU names the CURRENT view - "Lists" or "Table" - not the words "Board
View", the same as the board header, which says "Swimlanes" or "Lists" rather
than naming the menu. Lists is the default and is what an account that has never
chosen sees. The choice is kept in localStorage rather than on the user document:
it is a view preference for one page and changes nothing anybody else can see. A
board's view IS on the profile, because it follows the user between devices; this
deliberately does not.
THE TABLE VIEW is the shared table page, editable - which is what separates it
from the other board table, /public, which is read-only. Columns: Edit, Board
title, Board description. Ten rows a page.
Edit opens the SAME popup the Swimlanes view opens from its board menu, not a
copy of it. That took one change: boardChangeTitlePopup's submit read
Utils.getCurrentBoard(), which is the board you are looking at - and on All Boards
you are not looking at one. It takes the board from its own data context when it
has one and falls back to the current board, so the board header is unchanged.
TWO THINGS THE REFACTOR NEEDED.
The state had to be shared. boardListHeaderBar and boardList are separate Blaze
instances - the bar is rendered into the layout's headerBar region, the page into
its content region - so the search field would have filtered nothing. The search
term and the selected section are module-level ReactiveVars now, assigned onto the
page instance so every `tpl.boardSearchVar.get()` already written keeps working.
And "which boards am I looking at" had to stay one answer: the `boards` helper's
body is `boardsForView(tpl)` now, called by both views, because two copies of a
membership + section + search + sort + paging query would drift.
The view module is split in two for the same reason the others are: which views
exist and which is the default is pure (models/lib/allBoardsView.js) and unit
tested; the ReactiveVars and localStorage are Meteor glue and cannot be loaded in
a plain Node test.
The guard in tests/addBoardTileHeight.test.cjs pinned the ORDER of the three
controls inside .path-right. That row is gone on purpose, so the guard now pins
the order where the controls are - and that the page body does not also carry
them, which is the thing that was wrong.
Thanks to xet7 !
The row carried the board's colour CLASS, but nothing in boardColors.css matches
it: every board colour there is written for `.board-list .board-color-X a` - an
anchor inside the All Boards list - and a table row is neither. So the background
never arrived, while publicBoards.css forced `color: #fff` on top of it. White on
white; only the emoji in a title was visible.
Two fixes, and the second is the one that actually mattered.
The colours now reach the row: `.public-board-row.board-color-X` is added to each
of the seventeen existing selector lists in boardColors.css - the same
declaration, one more selector - so the rows follow the board colours and the
theme without a single hex value being repeated in the new stylesheet. The test
counts both lists and fails if a colour reaches the All Boards tile but not the
row.
And a board with NO colour matches none of those rules however they are written,
so the row needs a default background of its own. That is the case that was
reported: without it the row keeps the table's white background and the light
text vanishes. The All Boards tile has the same problem and answers it the same
way - `.board-list-item` falls back to grey so its white text always has
something to sit on - so the row does that too, and the text is the tile's own
#f6f6f6 rather than a hardcoded white.
The route renders `boardListHeaderBar`, whose h1 already says "Public", and the
pane then supplied `titleKey: 'public-boards'` to the shared table page - so
"Public boards" appeared again directly underneath it.
The shared table page prints a title only when it is GIVEN one, for exactly this
reason: docs/Design/Page/Table.md says so in the template itself, and it is why
the Admin Panel panes supply none either - their heading comes from the active
left menu entry. This page had a heading too and asked for a second.
The test now pins it, and reads the LAST `return` of the helper: the earlier one
is the per-board row object, which legitimately has a `title` of its own - the
first version of the guard matched that and failed on the fix.
The eleven new keys of the Admin Panel / People / Roles pane, written directly -
by the assistant, from each language's own existing translations and general
kanban terminology - which is what CLAUDE.md's translation policy asks for. No
external translation service, API or key is involved, and nothing here is pushed
to Transifex: a filled string must never be able to masquerade as a human one.
Applied through releases/translations/fill-translations.mjs --apply, so the fill
can only ever write into a placeholder. It reported the same thing for nearly
every language - "filled 10, skipped 1 existing human translation" - because
'roles-status-comment' already had a real translation in most files, and the tool
refused to touch it. That is the guarantee working, not a rounding error.
The terms come from each language's own file rather than from English: the word
for a board differs everywhere (Tableau, Tablero, Quadro, Bacheca, Tavla, Taulu,
Tablica, Доска, Pano, Nástenka, 看板, ボード, לוח), and so does 'settings' and
'comment'. Several files are contaminated with other languages - the Greek file
has French and Italian values, the Korean has Japanese, the Thai has Vietnamese -
so those anchors were NOT copied; the strings were written in the actual target
language.
111 of the 142 non-English language files are done. THIRTY-ONE ARE NOT, and are
left as English placeholders on purpose: ace, ary, ast-ES, br, gu-IN, ig, oc,
or_IN, pa, ta, te-IN, tk_TM, tlh, ug, ve, ve-CC, ve-PP, vl-SS, vo, wa, wa-RR, wo,
wuu-Hans, xh, yi, yo, yue_CN, zgh, zh-GB, zu, zu-ZA. A wrong translation of a
permissions table is worse than an English one an admin can still read, and the
policy is explicit that the English source stays when there is no translation.
Regional variants take their base language's text (de-AT/de-CH/de_DE from de, the
eight Spanish variants from es, and so on), and the German wording avoids ß so
de-CH is correct as it stands.
Only the eleven keys changed, in 104 files: the diff touches no other key, key
ORDER still matches en.i18n.json everywhere, and the 43 files with pre-existing
formatting quirks are all files this did not touch.
releases/translations/verify-human-preference.mjs still passes 10/10.
docs/Design/Page/Public.md is the design; it describes only what is different
about this page and links back to docs/Design/Page/Table.md for everything shared,
which is that page's own rule.
WHAT IT WAS. /public rendered `boardList` - the All Boards page - with its query
swapped for `{ permission: 'public' }`. That brought the whole of All Boards with
it: the Starred / Templates / Remaining menu counting the user's OWN boards beside
a grid that was not, the workspaces tree, the org and team filters,
Multi-Selection with its archive and duplicate actions, the sort popup, board
dragging, and an "Add board" tile that made a private board from a page about
public ones. Multi-Selection offered to archive boards the visitor has no rights
to at all.
WHAT IT IS. Only the table. Two columns - board title and board description - ten
rows a page, paged and counted on the server. No left menu, no create, no
selection, no drag, and nothing on the page changes anything: a row's only action
is to open its board.
It supplies a rowTemplate rather than a text-cell column spec, because a row is a
LINK and carries its board's colour class and background image - so a board is
recognised here the way it is on All Boards (#5157) - and a text cell can express
neither. Read-only is about what the page can CHANGE: the row template renders one
anchor and no control.
WHAT A PAGE COSTS. Six fields for ten boards, not ten board documents: title and
description for the columns, slug for the link, color and backgroundImageURL for
the row. `members` is deliberately absent - it is the largest field on a busy
board and this page shows no avatars. The selector is built on the SERVER and
takes nothing from the client: public, not archived, a real board rather than a
template container, and not an internal ^Subtasks^ board. A search goes in as its
own $and term rather than replacing the title clause, or it would drop that last
exclusion.
Not carried over from the tile: member avatars, the per-list card counts, and the
spent-time clock. What a visitor needs of a board they do not belong to is what it
is called and what it is for; the rest is the inside of a board they have not
opened, and each costs a query the page would otherwise not make - the clock in
particular answers by looking for cards, which this page does not publish, so it
would read false for every board on every instance.
client/lib/reportPages.js now declares the DDP-only page-index collection, because
`new Mongo.Collection(name)` throws if the name is taken and it was declared
inside adminReports.js - so the second page to need it could not have one. The
guard in tests/adminPageRows.test.cjs that pinned that declaration is updated with
the reason: what it protects (declared once, from the shared constant, never a
typed string) is unchanged, it just lives in a different file.
tests/publicBoardsPage.test.cjs pins the route, that none of the All Boards chrome
is on the page, that a row carries no control, the two columns and one cell each,
that the labels are translation keys that exist, that paging is ten a page on the
server and the rows drawn are the ids the server named, exactly which six fields
the publication sends, that the selector is server-built and survives a search,
the row colouring, the shared collection, that the files are registered in the
bundle, and that every path the design doc names exists.
Thanks to xet7 !
The pull rebased the 14 local commits onto the released v10.53, so every commit
link written in the Upcoming section pointed at a hash that no longer exists.
releases/fix-changelog-hashes.sh followed all 11 by subject and repointed them;
only Upcoming is touched, and the released sections are pushed history.
WeKan creates boards of its own to hold machinery - a subtasks board, for one.
They are real boards and the user "owns" them, but nobody chose to make one and
nobody means to open one, so no list of boards shows them (#5582). They are
recognised by their title being wrapped in carets: `^Subtasks^`.
The selector for that was typed out at each list - five copies of the same
`{ $not: { $regex: /^\^.*\^$/ } }` - and the sixth place forgot it. /public
built its own query and listed every public subtasks board on the instance beside
the real ones.
models/lib/helperBoards.js is that selector now, once, and every list uses it -
the four in boardsList.js, userBoards in models/boards.js, and /public, which
gains it. A FUNCTION rather than a shared constant: Mongo selectors get merged and
mutated by their callers, and a shared object would carry one caller's edit into
the next list.
The test fails if a copy of the pattern comes back anywhere, if a board list stops
using the helper, or if /public in particular does - and pins what the pattern
does and does not match, so a board somebody really named "Subtasks" is never
hidden.
Thanks to xet7 !
The board scope came from Boards.userBoards, whose $or lists the ways a user
reaches a board - member, organization, team, e-mail domain - and
`{ permission: 'public' }`. That last one is the odd one out: it is not a
relationship to the user at all, it is "anybody may open this".
That belongs in the boards LIST, where a public board is meant to be
discoverable. In a search it meant every public board on the instance was
searched: on a public server a common word answered with strangers' cards, and
following a hit dropped the user into a board they have no part in. Someone who
wants to look inside a public board can still open it and search there.
`includePublic` is an option on userBoards / userBoardIds, defaulting to TRUE so
every existing caller - the boards list, the lists and comments lookups - is
unchanged. The search publication names it once as SEARCH_BOARD_SCOPE and passes
it to all four board lookups plus the `board:` filter's name resolution: one
missed lookup and that branch of the search still reaches the whole instance,
with nothing looking wrong.
Boards.userSearch takes the same option, because it is what resolves `board:name`
- left unscoped it would name a public board the search itself can return nothing
from, which is a filter that silently empties the results. With public boards
excluded an anonymous caller can reach nothing at all, and an empty $or means
"match nothing" in Mongo but is an error in some backends, so that case is
answered directly instead of being sent to the database.
Only the four real relationships stay unconditional; the test fails if any of them
becomes optional, if a board lookup in the search loses the scope, or if
models/boards.js ever switches public boards off for everybody.
Thanks to xet7 !
It scaled the board with a CSS transform, it did not work, and there is no plan to
fix it - WeKan already has a font-size setting (Member Settings / Change Font),
which is what the control was reached for anyway. A control that does nothing is
worse than no control, so it is gone rather than hidden.
What went with it: the pill and its number in the header, the helper and the three
handlers behind it, Utils.getZoomLevel / setZoomLevel / applyZoomLevel and their
three call sites, the `profile.zoomLevel` schema field, its entry in the published
user fields, the model setter and the server method, the `click-to-change-zoom`
translation string, and 57 stylesheet rules spread over four files - header.css,
layouts.css, boardHeader.css and boardsList.css - which is how much of the
stylesheet a broken feature had accumulated.
The card zoom is a DIFFERENT feature and is untouched: getCardZoom / setCardZoom /
applyCardZoom and the +/- buttons in the card header still work.
Three tests existed only to constrain the pill, and each is handled on its own
terms rather than deleted to make the run green:
* uiDensity's "zoom pill fits inside the 28px quick-access row" has nothing left
to measure; it is replaced by a guard that the pill has not come back into any
of the four stylesheets that carried it - it was spread widely enough that a
partial revival is a real risk.
* mobileAllBoardsFit's "the quick-access bar gives way" capped the zoom input at
50px of a 375px phone. The rest of that test - notifications and the user bar
never shrink, because they are what the user came for - is untouched, and the
zoom half is now the same "it has not come back" assertion.
* mobileAllBoardsFit's "the zoom number stays INSIDE its white pill" was a real
bug once (the pill shrank below its contents and the number ended up beside it
in 8px type under the notification bell). There is nothing left to lay out, so
it is removed with a note saying what it used to protect and where the
replacement guard is.
Thanks to xet7 !
The swatch list is shared with the board-BACKGROUND picker, where it is a
float-based TWO-column grid (boardsList.css: float: inline-start; width: 50%).
Two columns is right for background thumbnails; for the Select Color picker it
meant Flat, Clear, Dark and Special each ran down the popup in a narrow pair, and
most of them were below the fold however wide the browser was.
Auto-filling columns instead - the same answer the Change Language popup already
uses, for the same reason. `repeat(auto-fill, minmax(120px, 1fr))` takes as many
columns as fit and collapses to one on a narrow window, so no media query is
needed and a phone is unaffected. Scoped to .theme-color-picker, so the
board-background picker keeps its pairs; the shared float rules are undone
explicitly inside the grid, because a floated grid item is still floated and
width: 50% would leave every other column half empty.
Width buys columns, so both Change Color popups get more of it on desktop -
Member Settings and Board Settings, which are the two places the picker opens as
a popup. Desktop only: below 800px popup.css lays every popup out as a
full-screen sheet and an !important width would fight that rule and win.
The 720px is in TWO files and has to be, so it is stated in both comments and
checked by the test: client/lib/popupOffset.js clamps a popup into the viewport
using its width, and computed for the default 380px it placed a wide popup opened
near the right edge with a third of itself off the screen - the same class of bug
as #5667, which is what that module exists for. Ordinary popups still clamp
against 380px, which the test also pins.
It said "Apply" while every other settings form in WeKan says Save, and it was
pure black. Both came from one line: the submit carried no `.primary` class, so
it fell to the BASE button rule in forms.css - whose fallback is `#000` - instead
of the primary rule beside it, which is the themed one
(`var(--theme-accent, #005377)`). The Change Language form directly above it in
the same popup uses `input.primary.wide`, which is the shape this now matches.
The test guards both halves on that one line, so a Save that loses its theme, or a
form that goes back to "Apply", fails.
What each role may do was spelled out THREE times - in the server allow helpers,
in the client's canModify* helpers, and in prose in the docs - and the three had
drifted apart. Every place they disagreed was a role that did not do what its name
says. models/lib/boardRoleCapabilities.js is that table now, in code, and
everything reads it: the allow rules, the UI helpers, the new Admin Panel pane and
the documentation. There is nowhere left for a fourth opinion to appear.
THE THREE GAPS, fixed:
* "Comment only, assigned" (isCommentAssignedOnly) had FULL WRITE ACCESS.
Nothing outside the card publications read that flag and it was not in the
write rule, so the role could create and edit cards, lists and checklists - it
was "Normal, assigned only" under another name. It is comment-only now, like
the role it is named after; the two differ only in what they see.
* "No comments" (isNoComments) could not write ANYTHING. The write rule excluded
it, so the role blocked editing as well as commenting - a second read-only
role under a name that says otherwise, and one the UI still offered the edit
buttons for. It blocks commenting only now, which is what the name and the
board schema both say it is for.
* The write rule did not exempt board admins. Every has*() helper on the board
ignores a flag on an admin; that one read the raw flags, so an admin who also
carried isNoComments silently lost write access. Not reachable from the Web
UI, which writes all eight flags at once, but reachable over the REST API,
which takes them individually. memberRoleOf() resolves isAdmin first.
The UI helpers were part of the same drift - canModifyCard() did not exclude
isNoComments while the server did, and canModifyBoard() excluded neither
isNoComments nor isWorker - so each disagreement was a button offered to somebody
whose write the server then refused. All three ask the same table now.
A FOURTH gap found while building the table, NOT fixed: a Worker cannot move a
card, which is the one thing the role is for. Moving a card is a card UPDATE, so
it goes through the write rule, which excludes Worker. The fix is not a table
edit - it means letting a role write SOME fields of a card and not others, and
"assign himself" means validating that a member change only ever adds the caller.
That is a field-level policy on the path every card update takes and wants
deciding on purpose. It is recorded under "Known gaps" with what it would take.
ROLES STATUS, at Admin Panel / People / Roles, below the Save button: a READ-ONLY
table of what each role may do - which cards it sees, and whether it may comment,
create and edit, or change the board's settings. It is the shared table page
(docs/Design/Page/Table.md), so it looks and behaves like every other Admin Panel
table, with no markup of its own; it has no rowTemplate, no actions and no
editable cell, because a role's capabilities are a property of the code and not a
setting. Every string is a translation key, the Yes/No of each cell included - the
values are booleans, and a hard-coded "Yes" would be English in every language.
The "Invite to board" column reads the pane's WORKING copy, so the table follows
the checkboxes above the Save button as they are ticked, before saving, which is
the point of putting it there.
docs/Design/Page/Table.md listed Roles among the panes that do NOT use the shared
design, "so the gap is visible rather than forgotten" - it has a table now, so it
moved to the list of pages that use it, and the exclusion covers the two panes
that are still nothing but a form.
Two guards were checking the shape of the old code rather than what it guaranteed,
and are updated with the reason in the test: listDragPermission read the flag
exclusions out of canModifyBoard's own body (it has none now, so it checks the
BEHAVIOUR - anonymous is falsy, and each excluded role still cannot drag a list),
and tablePage counted the pager handlers file-wide, which also forbade a handler
on a different template - a separate event map is not a duplicate key.
tests/boardRoles.test.cjs is the suite: the module itself including each fixed
gap, that the server rules and the UI helpers ARE the table and name no flag
themselves, that every cell of the documentation matches it, that the
assigned-only column matches the publication scope, that the remaining gap is
still real and the three fixed ones are recorded as fixed, and that the Roles
Status pane renders from the table, is read-only, is the shared template, follows
the working copy, and translates every string it shows.
Thanks to xet7 !
Three entries for the commits that had none: the boardCardsWindow assigned-only
restriction, the board-roles table under docs/Features/Members/, and the
aldeed:collection2 4.2.0 -> 4.2.1 bump as a dependency bullet.
There was no comparison of the roles anywhere. docs/Features/Members/Members.md
listed three of them - Admin, Normal, Comment only - in one line each, and there
are NINE: board admin, normal, no comments, comment only, worker, read only, and
an assigned-only variant of normal, comment-only and read-only. docs/API/Role.md
shows how to set each flag over the API without saying what any of them does.
docs/Features/Members/Roles.md is the table: for every role, which cards it sees,
whether it may comment, create or edit cards, move cards, edit lists and
swimlanes, and change the board's settings and members. It also says WHERE each
column comes from, because the answer is only two helpers -
allowIsBoardMemberWithWriteAccess() and allowIsBoardMemberCommentOnly() in
server/lib/utils.js - plus isBoardAdmin() and, for visibility, the assigned-only
scope in the card publications. The table is what the SERVER allows, because the
server is the authority and the UI can only hide buttons.
VALIDATED FROM THE CODE, and three things do not match their own names. They are
recorded as gaps, not fixed here, because each needs a decision about which side
is wrong:
* "Comment only, assigned" (isCommentAssignedOnly) has FULL WRITE ACCESS.
Nothing outside the card publications ever reads that flag - it is not in
allowIsBoardMemberWithWriteAccess(), where isCommentOnly is - so the member
may create and edit cards, lists and checklists. The role is in practice
"Normal, but only sees the cards assigned to me", which is what
isNormalAssignedOnly already means.
* "No comments" (isNoComments) cannot write ANYTHING. That helper excludes it,
so the role blocks editing as well as commenting - while the schema describes
it as "is the member not allowed to make comments" and the UI's
canModifyCard() does not exclude it, so the member is offered the edit
affordances and the server refuses the write. The name and the schema comment
both point at the helper being the wrong side.
* The write helper does not exempt board admins. Every has*() helper on the
board requires isAdmin: false, so a flag on an admin is ignored, but that one
reads the raw flags. Not reachable from the Web UI, which writes all eight
flags at once and never leaves two set - but reachable over the REST API,
which takes them individually.
A fourth section lists where the UI is more generous than the server, i.e. the
buttons that do nothing: canModifyCard() does not exclude isNoComments, and
canModifyBoard() excludes neither isNoComments nor isWorker. None of these is a
security hole - the server rules hold - but a button that silently fails reads as
a bug to whoever clicks it.
tests/boardRoles.test.cjs keeps the page honest rather than trusting it, which is
the point: a permissions table that quietly goes stale is worse than none, because
it is what an admin decides who to trust with. It parses the table and checks that
every role memberRole() can return has a row and every row names a flag it really
reads, that the "create / edit" and "comment" columns match the flag lists in the
two server helpers, that the "which cards they see" column matches the
assigned-only scope, that only the board-admin row may change settings, that each
gap the table marks is still real AND still explained - so fixing one has to
update the page with it - and that the page is linked from both Members.md and
the API role page.
Thanks to xet7 !
Resolved by the Meteor package resolver; .meteor/versions is the lockfile that
records it. collection2 is what cleans and validates every write against a
collection's SimpleSchema, so it sits on the path of every insert and update
WeKan makes.
Three board-member flags mean the same thing - isReadAssignedOnly,
isNormalAssignedOnly, isCommentAssignedOnly - the member may only see the cards
they are assigned to. The `board` publication has always narrowed its card cursor
with `assignees: { $in: [userId] }` for such a member. `boardCardsWindow`, which
is what ships the cards in LAZY card-loading mode, did not.
So whether the restriction applied at all depended on the board's card-loading
mode. The same member saw only their own cards on a small board and EVERY card in
the window on a big one (or on any board with CARDS_LOADING=lazy) - and with the
cards went their comments, attachments, checklists and checklist items, because
the window's children hang off the same selector.
The restriction is part of the window scope now, and of its COUNT: an
unrestricted `boardListCardCount` still told the member how many cards the list
really holds and offered to scroll in cards that would never arrive.
Two things this had to get right.
The FIELD PROJECTION. publish-composite hands each child the document as the
PARENT cursor published it, and that cursor projected to `{ _id: 1 }` - so
`board.members` was undefined in every child and the restriction would have been
dead code. The parent publishes `members` now, which also makes it reactive:
changing a member's assigned-only flag re-runs the window instead of taking effect
on the next subscribe. The board publication already ships this board's members to
the same client, so nothing new is exposed.
The MERGE. The scope was spread into the client's selector at the top level
because FerretDB v1 (SQLite) does not push a top-level `$and` down to its index -
the wrapped form full-scanned the whole cards table on every poll and cards never
loaded on a big board (10.22). But a top-level spread can only be used when the
two selectors do not both speak for the same key, and the board Filter HAS an
assignee filter, so this collision is reachable from the UI. In the direction the
publication spreads them the client's value would have won and the restriction
would have been silently dropped. The inline `safeCollides` guard covered
boardId/archived only; it is now mergeCardScope in models/lib/boardCardScope.js,
which merges when the keys are disjoint and falls back to `$and` - where BOTH hold
- when they are not. An assigned-only member filtering for someone else now gets
nothing rather than everything, and an unrestricted member keeps the fast path.
tests/boardCardScope.test.cjs covers each of the three flags, an ordinary member,
an INACTIVE assigned-only member document (matching the publication's
isActive: true lookup), a non-member and a logged-out visitor - both deliberately
unrestricted here, because this narrows what a MEMBER sees and board visibility is
decided before it - malformed member arrays, the assignee-filter collision, that
an unrestricted member keeps the top-level merge, that BOTH the window and its
count are narrowed, and that the parent cursor publishes the members its children
read.
Thanks to xet7 !
"If I use any filter, the modal that appears on screen sometimes doesn't
disappear. Ideally, this should close the moment I click anything outside the
modal."
It is the board sidebar showing its filter view, and nothing dismissed it but the
sidebar's own toggle or Escape. The reason is one argument: the document click
handler in client/lib/escapeActions.js runs
`clickExecute(evt.target, 'multiselection')`, and `sidebarView` sits BELOW
`multiselection` in the hierarchy, so `_execute` returns before ever reaching it.
A click could not close the sidebar by design.
The handler lives in the filter template rather than raising that limit, because
raising it would make EVERY sidebar view close on any outside click - Archive,
Settings and Card Settings are panels people work beside on purpose, and only the
filter reads as a thing you open, use and are done with. Escape is untouched: the
existing sidebarView action still returns the sidebar to its default view, and
this change did not have to alter its registration.
Three clicks deliberately do NOT close it. Inside the panel, obviously. A pop-over
the panel opened - the label, member and due-date pickers render outside the
sidebar, so without this, choosing a value in one would close the panel behind it.
And the header button that opens the filter, which would otherwise toggle it shut
in the same gesture that opened it. The handler is also bound on the next tick, so
the opening click - whose flush can still be in flight when the template renders -
cannot reach the handler it just created; a panel destroyed before that tick
clears the timer rather than leaving one behind. It is namespaced and removed by
name in onDestroyed, so it can never outlive the panel and close the sidebar under
some later view.
The sidebar is hidden, not reset: reopening it comes back to the filter you were
using. Escape is the one that returns to the default view, as before.
tests/filterSidebarOutsideClick.test.cjs pins the reason as well as the fix - that
a click really cannot reach sidebarView through EscapeActions, so a later
"simplification" that moves this into escapeActions has to argue with it first -
and each of the three exceptions, the next-tick bind, and that Escape still does
what it did.
Thanks to xet7 !
Two problems from one report with screenshots, both in how a list decides what to
draw at its bottom edge.
1. "ONCE A FILTER IS APPLIED FOR A MEMBER, THE 3 DOTS CONTINUE TO ANIMATE." The
screenshot shows a list with NO cards under the filter and the load-more spinner
still turning under it - and `_updateList` raising the window limit by ten every
idle callback for as long as it stayed on screen.
The spinner asked "does this list hold more cards than the window I asked for?"
and answered it from a total built somewhere else than the cards being drawn. The
cards come from the filtered selector; the total came either from
`list.cards(swimlaneId).length` or, in lazy card-loading mode, from a count
document published per list/swimlane - whose id was `listId::swimlaneId` and did
NOT include the filter. So changing the filter re-subscribed with a new selector
under the SAME document id, two publications wrote one document, and Meteor's
merge box serves whichever subscription it prefers, which during the changeover is
the older, pre-filter one. The list drew its empty filtered window while its count
still described the unfiltered list.
The count document now carries a short stable key for its selector, so each filter
counts into its own document and a count can never describe another filter's
cards. Both subscribers pass it; the two-argument form still returns the old id
for a caller with no selector to hand.
And the spinner no longer depends on that being right. There is nothing more to
fetch unless the window came back FULL: if we asked for twenty cards and got
three, those three are all there are, whatever any count says. That holds in both
card-loading modes and for a stale count in either direction, which is why it is
worth asking as well as the total.
While there: showSpinner asked `isLazyCards()` with no board while cardsWithLimit
asked `isLazyCards(list.boardId)`, so the two could take different branches for
the same list. Both ask about the board now, and both build the selector the same
way instead of from different sources - that disagreement was the bug.
2. "RANDOM BLANK CARDS ARE APPEARING ON THE BOARD." White minicards with the
handle icon and nothing else - no title, no members, no labels - mixed in among
the real ones. In the screenshot the only two that render are the two carrying the
viewer's own avatar.
A minicard renders blank when its document is in minimongo without its fields:
`Cards.getTitle()` returns null for `title === undefined`, and every badge is
conditional on a field that is not there either. That is NOT a card with an empty
title - the schema declares `title` as optional with `defaultValue: ''`, so a card
that went through it has the key, `''` at worst. A document without the key at all
is a partial replication, and several publications ship cards with a projection:
`openCardData` publishes `{ _id: 1 }` as the parent of its children cursors, the
search publications ship their own field lists, and minimongo merges what every
live publication says about an id.
WHICH of them produced these particular stubs is not settled - it needs the
running board to catch - so this is a guard where the card is drawn rather than a
fix at the source: a document that does not carry the field the minicard is built
around is not drawn. `$exists` is what separates the two cases; filtering on a
falsy title would hide real cards. It cannot hide a card, and when the full
document arrives the card appears, which is what the blank box was standing in for
anyway.
tests/listCardWindow.test.cjs covers the reported case (a filter empties the list
and the total still says 25), a short window in general, that a full window with
more behind it still offers to load more, a stale total in both directions,
missing input, that an empty title is still drawn while a missing one is not, that
the selector stays flat and an existing `title` clause is not silently replaced,
that two filters no longer share a count document while the same filter always
names one whatever order it was built in, and that every subscriber names it the
same way.
Thanks to xet7 !
"notice how the card info docks neatly right next to the card" - the 6.09
behaviour asked for in #6465. The window had been moved off the middle of the
board and docked to the end edge instead, which stopped it covering the board but
left it nowhere near the card it belongs to: open a card in the FIRST list of a
wide board and its details are a screen away, with the whole board between them.
It now opens beside the minicard it was opened from, on whichever side has more
room, and always entirely inside the visible area. A card on the left of the
board opens to its right; a card on the right opens to its left.
This is X ONLY. The vertical geometry - the staggered `top` and the `bottom: 8px`
that makes the window full height - is already right, so nothing here writes
`top`, `bottom` or `height`, and a test fails if it starts to.
client/lib/cardDetailsPlacement.js is the decision, pure and unit-testable
without a DOM (like client/lib/cardDragGeometry.js beside it); cardDetails.js is
the DOM half. The width is passed through UNCHANGED unless the viewport itself is
narrower than the window. Shrinking the window to fit the gap beside the minicard
was tried and dropped: on a 600px-wide desktop it turned a 520px window into a
240px one, and this is about where the window is, not how big it is. When neither
side has room it overlaps the card instead, pushed as far towards the roomier side
as the viewport allows - the card is behind it for a moment, which is
recoverable; a window off the edge of the screen is not.
Nothing is placed unless there is something to place it against: a card opened
from a URL, from search, or whose list is scrolled out of view has no minicard on
screen, and keeps the stylesheet's dock. Only the desktop floating window is
touched - the popup form (Board Table, search results) and the mini-screen card
have their own geometry, and the maximized window's insets are !important, so
writing inline styles for it would be a no-op that only looked like it did
something. A window the user has DRAGGED keeps where they put it; the viewport
clamp still applies to it, so a browser window that shrinks cannot leave it
hanging off the edge.
The one vertical side effect that had to be paid for, in cardDetails.css: the
default-position rule is `:not([style*="left"]):not([style*="top"])`, so writing
an inline `left` switches it off - and it carried the `top` as well as the width.
Without a replacement, a SIXTH open window (past the five staggered nth-of-type
rules, reachable with "Open many cards at once") would have had no `top` at all.
The replacement is Y-only and written BEFORE the stagger rules, so cards 1-5 still
take their staggered `top` from those and no vertical position changes anywhere.
The test pins that order, because the same rule written after them would win the
specificity tie and flatten cards 2-5 onto card 1.
Re-placed on resize, coalesced into one animation frame. The inline geometry is
cleared before measuring, so what is measured is the width the stylesheet wants
rather than this function's own previous output - otherwise one run on a narrow
viewport would clamp the width and every later run would re-clamp that clamp, and
widening the browser again would never give the window its full width back.
tests/cardDetailsPlacement.test.cjs covers the placement for a card at each end of
the board, that the side is chosen by which has more room rather than by a fixed
edge, that a tie breaks towards the reading direction, that the window stays on
screen for every anchor position across the viewport including one scrolled
partly out of view, the narrow-viewport and neither-side-fits cases, that the
width is never grown, that a missing anchor places nothing - and the wiring: both
drag handles mark the window as user-placed, the reset happens before the
measure, and no vertical property is ever written.
Thanks to csonkaoszimt, Mintyt and xet7 !
Fixes#6465,
The values survived the move; their keys did not. Each entry came out with
`_id: ""`, which no board can match to a field definition, so the card arrived on
the destination board with every custom field showing empty. Drag-and-drop, the
Move card popup and the REST API `newBoardId` move all go through
Cards.helpers.move(), and Cards.helpers.copy() had it too.
`mapCustomFieldsToBoard()` was SYNCHRONOUS and called
`ReactiveCache.getCustomField()`, which is async on the SERVER - it awaits
findOneAsync - and synchronous only on the client. So on the server both lookups
returned Promises. A Promise is truthy, so `if (!oldCf)` never fired and the
`if (newCf)` branch always did, assigning `newCf._id`: `undefined` on a Promise.
The schema declares `customFields.$._id` as `optional: true, defaultValue: ''`, so
collection2 cleaned that undefined to `''` on the way to the database. Nothing
threw and nothing was logged.
Both lookups are awaited now, and so is `oldCf.addBoard(boardId)` - also async,
and until the two above were awaited its branch was unreachable, so its own
missing await had never been exercised.
Two things found while fixing it, in the same function:
* The entries are rebuilt rather than mutated. copy() deliberately works on a
shallow copy of the card "to avoid mutating the source card in ReactiveCache",
and `cf._id = newCf._id` reached straight through that copy into the source
card's own entry objects - re-keying the card being copied FROM.
* An entry with no id is passed through instead of looked up.
getCustomField() defaults its selector to `{}`, so `getCustomField(undefined)`
returns an ARBITRARY custom field, and a blank entry - including one blanked
by this very bug - would have been re-keyed to whichever field came first.
This is the same defect as #6504 (unawaited ReactiveCache.getBoard in this same
cross-board branch of move(), which gave "newBoard.getNextCardNumber is not a
function"), one line further down. The guard added then named the two getBoard
calls and stopped there, so the third call shipped broken for twenty more
releases; tests/cardMoveAwaitBoard.test.cjs now checks the branch as a whole and
fails on any unawaited async call in it - verified against the pre-fix file, where
it reports mapCustomFieldsToBoard.
tests/cardCustomFieldsMove.test.cjs lifts the real function out of models/cards.js
and runs it against both worlds - an async ReactiveCache (the server, where this
broke) and a synchronous one (the client, where it always worked) - which must
give the same answer: a value whose field exists on the destination board by name
and type is re-keyed to that board's definition, and one whose field does not is
left pointing at the definition it had, which gains the destination board so the
value still resolves. It also pins that addBoard is finished before the array is
returned, that an unknown field id costs no value, that a blank entry is never
matched against an arbitrary field, that the source card keeps its own ids, and
that both call sites await.
Cards moved or copied between boards on an affected release are NOT repaired by
this: the entry kept its value but lost the only reference to which field it
belonged to, so there is nothing left to match it back with. Those values have to
be set again.
Thanks to ChristianMa97 and xet7 !
Fixes#6560,
Ticking "Propagate Members To Boards" for a team in Admin Panel > People > Teams
stored the flag and added nobody. The members only appeared later, when the LDAP
sync cron ran the propagation by its other, correct route.
`setTeamPropagateMembersToBoards(team, value)` receives `team` as the SELECTOR the
client sent - `{ _id: … }`, the same value it hands to `Team.updateAsync` on the
line above - and passed it whole:
await propagateGroupMembersToBoards('team', team);
`propagateGroupMembersToBoards(kind, groupId)` puts that straight into the member
lookup, so it ran
Meteor.users.find({ 'teams.teamId': { _id: 'abc' } })
comparing a string field against an object. It matched nobody, the function
returned early with "0 boards updated, 0 members added", and nothing threw. The
checkbox went green and did nothing.
The org column beside it was identical - `setOrgPropagateMembersToBoards` passed
`org` the same way - and is fixed with it, though the report was about teams.
Both call sites pass the id now, and `propagateGroupMembersToBoards` normalises
whatever it is given at the one point every caller goes through: a plain id, or a
document/selector carrying one. Anything it cannot turn into an id is REFUSED AND
LOGGED rather than quietly treated as a group with no members, because a silent
"0 members added" is exactly how this survived a release. A missing group stays
quiet - propagateAllFlaggedGroupsToBoards may legitimately have nothing to pass.
The select-all checkbox in the column header is the same promise to the admin, and
it did not propagate at all, not even wrongly: setAllTeamsFeature /
setAllOrgsFeature set the flag on every row and stopped. They now propagate when
that field is the one being turned ON, and only for their own kind, so ticking the
team column does not act on the org column beside it.
propagateAllFlaggedGroupsToBoards takes an optional kind for this; the default is
unchanged, so the LDAP cron and the propagateOrgTeamMembersToBoards method still
do both.
The two wiring tests in tests/propagateMembers.test.cjs REQUIRED the buggy calls -
they asserted `propagateGroupMembersToBoards('team', team)` verbatim, pinning that
a call happened without asking whether its argument was right, and so held the bug
in place. They require the id now and fail if the whole selector comes back, and
the normaliser is exercised for real: every shape a caller might hold an id in
resolves to it, a selector with no id resolves to null AND warns, and an absent
group is quiet.
Thanks to ChristianMa97 and xet7 !
Fixes#6559,
The crash loop is gone - a transient database error no longer ends the boot
(00startupResilience) or the process (00processErrors) - and the reporter's 10.49
log is the same contention arriving in front of a user instead:
Exception while invoking method '/users/updateAsync' MongoServerError:
... [collection.go:191 sqlite.(*collection).UpdateAll]
[db.go:151 fsql.(*DB).InTransaction] database is locked (5) (SQLITE_BUSY)
That error goes to the CLIENT. The user's edit fails, and they reload to find out
what happened - which is exactly the complaint, "they only see new cards after a
reload". And it was not supposed to: models/lib/databaseErrors.js has classified a
locked SQLite as { id: 'deadlock', act: 'retry' } with "Retried automatically."
since it was written, and 00processErrors says "the write is retried by whatever
issued it". Nothing read `act`. No code in WeKan retried anything, anywhere.
SQLITE_BUSY is the one database error a client is meant to handle itself. SQLite
has a single writer; a second writer arriving while the lock is held is told to
come back, and nothing was applied - FerretDB takes the lock before the
transaction (_txlock=immediate), so a BUSY answer means the write did not happen.
Retrying the same write is the documented behaviour and stays idempotent: Meteor
has already chosen the _id, so a retry that raced a duplicate is refused by the
unique index rather than inserted twice.
server/00retryBusyWrites.js wraps insertAsync / updateAsync / upsertAsync /
removeAsync on Mongo.Collection.prototype: a transient database error is retried
with exponential backoff and full jitter, bounded at 5 attempts and 2s in total
(WEKAN_DB_RETRY_*). Bounded on purpose - retrying for longer holds a method
invocation and its slot in the connection pool while the contention it is waiting
for gets worse. When it still fails the ORIGINAL error is thrown unchanged, so a
database that is genuinely stuck looks exactly as it did before and nothing is
hidden; anything that is not transient is rethrown on the first attempt, so a full
disk is not waited on four times before being reported.
It is loaded from server/imports.js, which runs after the Meteor packages, so this
wrapper sits OUTSIDE the ones collection2 and collection-hooks put on the same
prototype - and that is how it sees the error at all, because by then the busy
error is collection2's ValidationError with the database's message inside it,
which is the form in the reporter's SyncedCron traces.
Writes only. Every error in the reports is a write, which is what contends for the
one writer; SQLite in WAL mode does not block a reader against a writer, and
wrapping every read would put this on the hottest path in the server for no
reported failure.
Retries are counted and summarised at most once a minute, so a contended database
is visible without the log becoming the new problem, and a write finally given up
on is recorded for Admin Panel / Problems - except an eventlog write, which is how
the recorder writes, and reporting its failure would call the recorder from inside
itself.
The reporter asked outright whether PostgreSQL is an option for a snap
installation. It is, and it has been: the deadlock rule's advice now says how -
snap set wekan wekan-ferretdb-handler=postgresql wekan-ferretdb-url=... , or
docker-compose-ferretdb-v1-postgresql.yml in Docker - instead of only saying that
SQLite has one writer.
tests/sqliteBusyRetry.test.cjs loads the real module against a stub collection
prototype and exercises the behaviour: a busy write is retried and succeeds with
its arguments and its collection unchanged, collection2's ValidationError form is
recognised too, a full disk is thrown at once and never retried, an endlessly busy
database gets the original error after a bounded number of attempts AND a bounded
wall-clock time, the backoff grows and is jittered and capped on the shipped
numbers, the summary is rate-limited, reads are left unwrapped, wrapping twice is
a no-op, and the eventlog write is retried without reporting on itself.
Thanks to Nissulya and xet7 !
Fixes#6533,
Three attempts at this have added imports to client/lib/swcHelpers.js and 10.51
still failed in Yandex Browser with the same line. The stack says where to look:
Uncaught Error: Cannot find module '@swc/helpers/_/_possible_constructor_return'
at a.s [as link] (...) at client-rspack.js (...:285:985)
at client-meteor.js (...:285:86)
Read outward: client-meteor.js links client-rspack.js, and the failing link() is
inside client-rspack.js. But the bundle rspack writes contains no @swc/helpers
specifier at all - the built _build/main-prod/client-rspack.js has the helper
bodies INLINED and the string nowhere. So the import is in the copy of that file
that METEOR compiled, and Meteor put it there:
* package.json says "meteor": { "modern": true }, which turns on Meteor 3.3+'s
SWC transpiler for every file;
* packages/babel-compiler/babel-compiler.js sets jsc.externalHelpers: true
whenever node_modules/@swc/helpers exists - and WeKan depends on it - so the
output IMPORTS its helpers instead of inlining them;
* for web.browser.legacy that transpile passes no jsc.target and an env.targets
down to ie 11, so `class` is lowered to ES5 and the output gains
import { _ } from "@swc/helpers/_/_possible_constructor_return";
* that specifier is not in the legacy bundle's module tree and the link()
throws before anything runs. The modern bundle is unaffected: nothing there is
lowered that far, and modules-runtime/modern.js resolves
mainFields ['browser','module','main'] - the esm/ file - where legacy.js
prefers `main`, which is ../../cjs/_x.cjs.
That is also why the earlier fixes could not work. client/lib/swcHelpers.js is in
the RSPACK graph; rspack resolves its imports and inlines the helper bodies, so
they never become entries in Meteor's module tree, which is the tree the failing
link() searches. No import from app code can satisfy a link that Meteor's
transpiler writes afterwards.
So .swcrc sets jsc.externalHelpers: false and SWC inlines each helper into the
file that needs it: the import is not emitted, so it cannot go missing. Both
readers of that file merge it over their defaults and neither preserves this key -
Meteor's deepMerge keeps only jsc.target, env.targets and module.type, and
@meteorjs/rspack's cleanOmittedPaths only jsc.target - so it reaches the Meteor
transpile and the rspack build alike. It costs a copy of a helper per file that
uses one, which is the right trade for a bundle that does not load at all.
.gitignore's `*.sw*` is for vim's .swp/.swo and matched .swcrc too, so adding it
was silently a no-op; `!.swcrc` follows it, and the test fails if that exception
is removed or moved above the rule that needs it.
Only browsers served the legacy bundle could hit any of this, which is why it read
as a Yandex bug. useragent-ng reports Yandex Browser as family "Yandex Browser",
webapp camel-cases that to yandexBrowser, and modern-browsers has neither a
minimum nor a chrome alias for that name - isModern() is false for a name nobody
declared - so every Yandex Browser user was served ES5. server/modernBrowsers.js
declares yandexBrowser 18: its major version is a year, and 18.x is Chromium 64,
past full ES2015 (51) and dynamic import() (63). Samsung Internet, Vivaldi, Opera
Mobile, Whale, MIUI, UC and QQ Browser are in the same position and are left
alone on purpose - their version numbers do not map onto a Chromium version - and
the file says so rather than looking like they were forgotten.
tests/modernBrowsers.test.cjs reproduces Meteor's decision (webapp's camelCase,
modern-browsers' alias table and lookup) against real user agent strings, so it
fails if the key stops matching the family name or the minimum stops covering the
versions in the reports. tests/swcLegacyHelpers.test.cjs now pins .swcrc, that
the keys the readers preserve are NOT set there, the gitignore exception, and that
swcHelpers.js still says it is a diagnostic rather than the fix.
Thanks to zubzhaaaw and xet7 !
Fixes#6557,
A database problem arrived in Admin Panel / Problems / Database problems
with an empty Category, Name and Action, a severity of warning, and a
detail that said "The database returned an error WeKan has no rule for.
Read the message below" - with no message anywhere below, or above, or on
the page at all. So the row said that something happened and nothing
about what.
Nothing was wrong with the classifier or the recorder. EventLog has a
SimpleSchema attached, and collection2 cleans every insert against it
with filter:true, so a field the schema does not declare is dropped
silently on the way to the database. The four fields the database stream
uses - type (the rule id), db (which database said it), kind and message
- were never added to that schema, so they were the four things thrown
away, and they are exactly the four the page shows in Category, Name,
Action and the message it told the admin to read. The other four streams
were unaffected because they only ever write schema fields.
The schema declares them now, and a test pins the general rule instead of
the one instance: for each of the five event loggers, every key of the
document it inserts must be declared in the EventLog schema, so the next
field somebody adds to a logger cannot vanish the same way.
The message is shown too - the Detail cell carries WeKan's reading of the
error and then the database's own sentence, which is the one thing an
admin can search for or paste into an issue - and the stream search looks
at message, db, kind and type, so searching for "postgresql" or for a
phrase out of the message finds the row that displays it. The
unclassified advice no longer promises a message "below": the message
follows in the same cell.
Because that message is now stored and displayed, it loses its
credentials first. A database that refuses a login or cannot be reached
quotes the connection URL back, password and all - "failed to connect to
mongodb://wekan:s3cret@mongo:27017" - so classifyDatabaseError redacts
the userinfo of any URL in the message before returning it, keeping the
host, the port and the database name, which are what makes the message
useful. It is done there rather than in the recorder so no future caller
can forget it. The message is also sanitized like every other stream's
detail: one line, control characters out, capped.
Thanks to xet7 !
The rebase that landed v10.50 rewrote three of the commits the Upcoming section
links, so three links pointed at nothing; releases/fix-changelog-hashes.sh
repointed them by subject and author date. All 3024 commit links in the file
resolve to a commit in this clone again.
The section was also three commits short of what has been done since v10.50.
Added: the SNAP_AUTH message that blamed the Snap Store for a credential
snapcraft could not read, the Releases menu that makes every maintainer script
in releases/ reachable from build.sh and build.bat, the twelve one-line wrappers
that became commands plus the command line both scripts now have, and the
variant sync renaming both snapcraft files.
Every commit since v10.50 now has an entry except the changelog commits
themselves, which is the convention.
Thanks to xet7 !
The wrappers first. `releases/ufw-enable.sh` was one line - `sudo ufw enable` -
and so were eleven others: ssh-start/stop, snapd-start/stop, docker-start/stop,
multipass-remove, syncloop, git-add-revert, stable-tag and ufw-disable. Nothing
in the repository called them except the menus added yesterday, so they are the
command itself now, in the same menu entries. Checked one by one before
deleting: docker-build-deps.sh looked like the same kind of thing and is NOT
gone, because releases/docker-build.sh runs it.
An entry is "Group|Label|what|prompt|platform|key" now. `what` is a path under
releases/ or, with a leading "!", the command. `platform` is "linux" for the ten
that are systemd, ufw, snap or multipass: build.bat does not offer those,
because Windows cannot do them at all - it is not a parity gap, and the guard
knows the difference. ssh-start ran `systemctl enable ssh` twice; the entry
enables AND starts it, like its stop counterpart always did.
Then the CLI, which is what makes any of this usable from a script or a cron
entry:
./build.sh --help usage and examples
./build.sh --list all 84 names, with what each one does
./build.sh <name> [arguments] run one, without the menu
./build.sh release-snap 10.50
./build.sh push-translation ja
./build.sh ufw-enable what releases/ufw-enable.sh used to do
The name is the file name without its extension, or - for the twelve commands -
the name of the wrapper file it replaced, so anything anybody had in a script
keeps working. Arguments after the name are passed through unchanged. An
unknown name says so and exits 2 instead of doing nothing. build.bat has the
same thing, dispatched before the menu, and both menus have a "CLI commands"
entry that prints the help and the list.
tests/buildScriptParity.test.cjs gains: both scripts must dispatch from the
command line, every name build.sh answers to must work in build.bat unless it
is linux-only, the linux-only ones must NOT be in build.bat, and every example
in the help text must be a real name - which is the guard that catches a help
text teaching a command that fails.
Thanks to xet7 !
releases/ holds 90 maintainer scripts and FOUR of them were reachable from a
menu - the test run, the database conformance run, run-everything and the
CHANGELOG hash repair. Everything else existed only for whoever remembered the
file name: the bundle builds per architecture, the whole snap flow, the
Transifex scripts, the Sandstorm packaging, the tag and release helpers, the
Docker image publishing, the VM helpers.
They are one list now, `RELEASE_SCRIPTS` in build.sh, as
"Group|Label|path|argument prompt", in eight groups: Release, Snap, Bundles,
Docker images, Sandstorm, Translations, Git and repo, Server and VM. Pick a
group, pick a script; a script that needs a version, a language code, a branch
or a file is ASKED for it and gets it passed through, so it works from the menu
instead of only from a remembered command line. An .mjs entry is run by node,
everything else by bash.
build.bat has the same eight groups with the same scripts in the same order,
run through Git Bash - and it says so by name when bash is not on PATH, rather
than failing with "not recognized as an internal or external command".
Not in the menu, each for a reason the guard states: run-everything.sh,
db-conformance.sh and fix-changelog-hashes.sh (already in the Tests and Setup
menus), ensure-tools.sh (a helper the others source), ferretdb/* (they run
inside the built snap, bundle or Docker image), the superseded old-*.sh, and
build-bundle-win64.bat, which bash cannot run - the Bundles menu of build.bat
says to run that one directly.
tests/buildScriptParity.test.cjs gains the two guards that keep this true: every
.sh/.mjs/.bat in releases/ must be reachable from BOTH menus unless it is in the
documented skip list, and the two menus must offer the same scripts, in the same
order, asking for the same arguments.
Thanks to xet7 !
v10.50 built the amd64 snap, uploaded it, and died on
Publishing snap "wekan_10.50_amd64.snap"...
Unsquashing snap file 'wekan_10.50_amd64.snap'.
Credentials could not be parsed. Expected valid Ubuntu One credentials.
and the job then printed "SNAP_AUTH did not work: the Snap Store refused the
upload". The store refused nothing: snapcraft could not READ the secret. Sent
after the wrong problem again, and this time after a full snap build on every
architecture.
Three changes, no guessing about the credential format - that is snapcraft's
business, not this workflow's:
* the secrets check now rejects only what is unambiguously not a credential:
under 100 characters, or containing snapcraft's own "Exported login ..."
banner, which is what pasting the terminal OUTPUT instead of the file the
command WROTE looks like. That fails in one line at the top of the job
instead of after the build;
* the message after a failed upload no longer claims to know which failure it
was. It names both, and how to tell them apart from the snapcraft line
printed just above: "Credentials could not be parsed" is an unreadable
secret, 401/403 is a credential that lacks the ACL for that snap NAME;
* every re-export command now names all three snaps, because one release
publishes wekan, wekan-ondra and wekan-gantt-gpl:
snapcraft export-login --snaps wekan,wekan-ondra,wekan-gantt-gpl \
--acls package_access,package_push,package_release,package_update \
snap-auth.txt
gh secret set SNAP_AUTH --repo wekan/wekan < snap-auth.txt
The same distinction is in the Launchpad upload message, which had a third
possibility of its own ("is not a valid file" = the build produced nothing).
Thanks to xet7 !
Snap-Ondra-Gantt.md described the design as it was planned, not as it works:
the snap depending on the repository push, one architecture, one snapcraft
file renamed. It now says what the workflow does - two independent decisions
(publish the snap / update the repository), amd64 and arm64, both snapcraft
files, and the name check before the upload - and that the two repositories
were synced by hand on 2026-07-29.
It also points at the manual Docker path, which is new: wekanteam/wekan-gantt-gpl
and wekanteam/wekan-ondra are published by .github/workflows/docker-variant.yml
or releases/docker-publish-variant.sh, by retagging the released
wekanteam/wekan manifest.
Thanks to xet7 !
People are still on the older snap names, so a release that publishes only
`wekan` leaves them on a stale package. Two things stood in the way, and both
were in snap-variants.
The job gated EVERYTHING on WEKAN_REPO_TOKEN being able to push to
wekan/<variant>. Building and publishing a snap needs SNAP_AUTH and nothing
else; keeping the variant GitHub repositories in step with wekan/wekan is a
separate, optional thing. Because the two were tied together, a token without
push rights meant no variant snaps at all - that is why v10.48 and v10.49
published none. Now the guard answers two questions instead of one: `snap` (is
SNAP_AUTH set) decides the build and the publish, `sync` (may the token push)
decides only whether the repository is updated, and every "cannot push" warning
ends with "The snap is still built and published".
The variant tree is also no longer taken from a clone of the variant
repository: it is rsynced from the release tag this job checked out, with the
snap name and title sed into both snapcraft files. So the snap is built from
what was just released, whatever state the variant repository is in - and the
optional push sends that same tree on.
Both variants now build on amd64 AND arm64, the two native runners the default
snap uses, instead of amd64 only, and publish to stable + candidate + beta +
edge like the default snap: base core24, grade stable, so all four channels
accept it.
A last guard before the upload: the built file's name must start with the
variant's snap name. A build that produced wekan_*.snap here would overwrite
the DEFAULT snap in the store with a variant.
SNAP_AUTH must carry the ACL for all three names:
snapcraft export-login --snaps wekan,wekan-ondra,wekan-gantt-gpl \
--acls package_access,package_push,package_release,package_update \
snap-auth.txt
tests/releaseWin64AndVariants.test.cjs pins that the build and publish depend
on `snap` and never on `sync`, that the push is the part that needs the token
and runs once per variant, that both variants cover both architectures, and
that a wrongly named snap is refused.
Thanks to xet7 !
The header of snapcraft.yaml still described an older policy - "the workflow
publishes it to the candidate + beta + edge channels; the stable channel is
published MANUALLY later" - which has not been true for a while: snap-native,
snap-launchpad and snap-variants all publish stable + candidate + beta + edge,
for the default snap and for the wekan-ondra and wekan-gantt-gpl variants.
Both headers now say the actual reason for the base: core24 is released, so the
snap can be grade: stable and the stable and candidate channels accept it.
core26 is still experimental - build-base: devel forces grade: devel, and a
devel-grade snap is refused by stable and candidate, so it could only reach beta
and edge. That is why the release builds core24 and keeps the core26 file for
testing only.
The guard now pins all of it: snapcraft.yaml is base: core24 with grade: stable
and no build-base, every publish in the workflow names all four channels, and
snapcraft-core26.yaml is only ever renamed by the variant sync, never built.
Thanks to xet7 !
snapcraft.yaml is what the snap-variants job builds; snapcraft-core26.yaml is
the same snap on the next base. The sync step seds only the first, so the
core26 file in wekan-ondra and wekan-gantt-gpl kept saying `name: wekan` - and
a core26 build from either repository would have published itself as the
DEFAULT WeKan snap. Both files are renamed now, and the guard pins it.
Also ignores /wekan-ondra/ and /wekan-gantt-gpl/, which is where those two
repositories are cloned when they are synced by hand from this working copy.
They are whole git repositories, so they were never at risk of being committed
into this one, but they do not need to be untracked noise either.
Thanks to xet7 !
The release consumed the previous one, so this recreates it with what has been
done since: the SIGPIPE fix that ends the win64 mystery (a matched file
reported as missing, because grep -q closing the pipe makes pipefail report
141), and the #6558 browser test that was measuring the board's edge
auto-scroll instead of panning.
changelogFormat now excludes the closing "Thanks to above GitHub users ..."
sentence from its 80-column count. That line is prescribed word for word by
CLAUDE.md, so it is 92 characters in every release and cannot be wrapped
without changing the established form - and the file had reached exactly the
250-line budget, so writing the line the rules require failed the guard that
checks the rules. It counts the lines that COULD be wrapped now, and there are
249 of them, all in old entries.
Thanks to xet7 !
Its first run in the 00:12 suite: Chromium 248 passed, 1 failed - this test,
on `expect(during.lane).toBe(120)`, received 0. Everything it exists for
passed: the drag was really in progress, and neither the canvas nor the lane
carried `dragscroll` while it was.
The lane moved because the test grabbed the FIRST card of the FIRST list, which
sits within 40px of the lane's left edge - EDGE_SIZE in
imports/lib/boardAutoScroll.js. Inside that zone the drag's own auto-scroll
takes over by design and moves the lane SCROLL_STEP (15px) per mouse event:
eight moves, 120 - 8*15 = exactly the 0 that was read. That is #443 working
(dragging a card toward an off-screen list scrolls the board), not the panning
this test is about.
Panning is 1:1 with the pointer and happens anywhere; edge auto-scroll happens
only at an edge. So the test now drags in the middle: it measures the lane and
canvas rectangles, picks the minicard with the most room inside a 70px margin
on every side, and moves it along a short diagonal clamped to that box. If no
card can be found that far from every edge - a viewport too small - it skips
with that reason instead of measuring the wrong mechanism. The board is seeded
with cards in four lists so such a card exists.
Thanks to xet7 !
v10.49 printed the listing the previous fix added, and it settles two releases
of guessing:
7z t: archive is intact.
7z l -ba listed 51850 entries; the first five:
2026-07-28 21:58:39 D.... 0 0 bundle
2026-07-28 21:52:07 ..... 9 9 bundle\.node_version.txt
2026-07-28 21:58:36 ....A 8828928 3365469 bundle\bsondump.exe
2026-07-28 21:58:39 ....A 55313920 26791675 bundle\ferretdb.exe
2026-07-28 21:52:07 ..... 243 168 bundle\main.js
##[error]Process completed with exit code 141
bundle\main.js is right there, and the step still failed - with 141, which is
128+13, SIGPIPE. `printf '%s\n' "$listing" | head -5`: head has its five lines
and exits, printf is killed writing to a closed pipe, `set -o pipefail` (which
GitHub's bash sets) makes that the pipeline's status, and `set -e` ends the
step. The evidence printed, then killed the job.
The same mechanism is the ORIGINAL v10.48 failure, and this is the part worth
keeping: `printf '%s' "$listing" | grep -qF -e "bundle\main.js"` - grep -q exits
the instant it MATCHES, printf dies of SIGPIPE, pipefail reports 141, and `if !`
reads that as "not found". So "wekan-10.48-win64.zip has no bundle/main.js" was
printed BECAUSE the file was found. Reproduced both, exactly, in bash.
Every listing now goes through a file and every reader reads the file: the win64
verify, the four `unzip -l "$zip" | grep -q " $want$"` checks on the other
platforms (same trap, only luckier about the pipe buffer so far), and the five
`gh release view ... | grep -qxF` asset checks. No pipe, no SIGPIPE, no
pipefail verdict on a command that did its job.
tests/releaseWin64AndVariants.test.cjs pins that nothing in the workflow pipes a
listing into grep or head, and that the win64 verify reads its listing from a
file.
Thanks to xet7 !
The Launchpad project page answers what three "Stopped" builds and twenty
minutes of build time each could not:
https://launchpad.net/xet7-craft-remote-build
Licence: None specified
"Purchasing a commercial subscription is required ... The licensing of your
project does not qualify for free hosting."
`snapcraft remote-build` files every build under that auto-generated project
(57 snapcraft-wekan-<hash> repositories, one per invocation), and
auto-generated means created with no licence. A project that does not qualify
for free hosting does not get free builders, so its builds end as "Stopped"
with no artifact - Pending, Building, Stopped, `Artifacts:` empty - with
nothing wrong in the build log, because nothing was wrong with the build.
Setting the project's licence to MIT / X/Expat fixes it, and only tells
Launchpad what LICENSE, package.json and snapcraft.yaml already say. That is a
click on Launchpad, not a change in this repository, so what is added here is
the pointer: when a remote build produces no snap, the job now prints the URL,
what to look at and what to set, because this is the one cause that leaves no
trace in the build log it just printed.
The finding, and the click, are written up in ../log/workflow/TODO.txt item 5.
Thanks to xet7 !
misattributing the failures.
Every one of these made a release job report the wrong cause, which is worse
than the failure itself: two of them sent the last round of work after the
wrong problem.
1. snap-launchpad never checked whether a .snap existed. In three steps it did
snaps=( wekan_${VERSION}_<arch>.snap )
and then counted the array. That string has no wildcard, so it is a literal
filename, `shopt -s nullglob` cannot empty a literal, and the count was
ALWAYS 1 whether or not the file was there. So s390x logged an empty
`Artifacts:` list, then "Remote build s390x succeeded", then
'wekan_10.48_s390x.snap' is not a valid file
##[error]SNAP_AUTH did not work: the Snap Store refused the upload
The Snap Store never saw an upload. It is a real glob now, the first match
must also be non-empty, and the upload step says "the Launchpad build
produced no artifact - this is NOT a SNAP_AUTH problem" for a missing file
and keeps the credential advice for a refusal by the store.
2. The Launchpad build log was downloaded and thrown away. It is the only thing
that says why a build ends as "Stopped", and the job printed it only when
snapcraft's exit code was non-zero - which, after (1), it was not. It is now
printed whenever there is no snap, along with `ls -la`, and the "exit 0 but
nothing built" case says so in words.
3. build-win64 verified the zip by listing it into a variable and grepping that,
so a failure printed the verdict and none of the evidence: "has no
bundle/main.js" with no way to tell a bad zip from a bad grep. What can be
checked robustly now is: bundle/main.js, bundle/node.exe and
bundle/start-wekan.bat exist and are non-empty BEFORE zipping, and the
archive passes `7z t`. The listing is printed - five lines and a match count
per file - and a zero count is a warning naming both facts that contradict
it, not a failed release.
4. The snap-variants pre-flight asked GitHub for `.permissions.push` on the
variant repository. That field is the authenticated USER's role, not what the
token may do: it answered true, and the push one step later got
remote: Permission to wekan/wekan-gantt-gpl.git denied to xet7
It now asks the receive-pack advertisement - the first thing a real push
requests, and what GitHub answers 403 to for a token without Contents:write -
with the token in curl's -u rather than in a URL, and no git command, since
this step runs before actions/checkout.
What still needs a person is unchanged and written up in ../log/workflow/TODO.txt:
why Launchpad stops the ppc64el and s390x builds after ~20 minutes, and giving
WEKAN_REPO_TOKEN write access to the two variant repositories.
tests/releaseWin64AndVariants.test.cjs is new and pins (3) and (4);
tests/releaseSnapArches.test.cjs gains "every snaps=() must be a real glob" and
"the build log is printed whenever there is no snap", and its
"SNAP_AUTH did not work" assertion is replaced by the two messages that took its
place, with a note saying why. Both read the workflow with comments stripped -
the comments quote the broken lines on purpose.
Thanks to xet7 !
releases/fix-changelog-hashes.sh - run by build.sh's "Update git ..." after a
rebase and by release-all.sh before a release - decided a link was stale when
its commit was not an ancestor of HEAD. That is the wrong question. A commit
that lives only in an old release TAG is not on this branch, and GitHub serves
it perfectly well; calling it stale and repointing it sends the reader to a
DIFFERENT change, which is worse than the dead link it was trying to fix. Two
2019-era links were repaired that way while fixing the file today, and had to
be put back.
Staleness is now "no ref in this clone - branch or tag - reaches it", read once
from `git rev-list --all` rather than by walking the history per link (which
--all-sections would turn into thousands of walks; the whole file takes about
ten seconds).
When a hash really resolves to nothing, the replacement is now looked for in
order of how strong the evidence is:
1. same subject AND same author date - a rebase keeps the author date and
replaces the commit date, so it is the one to compare;
2. same subject, and only ONE commit has it - a subject like "Updated
ChangeLog." or "Bump versions for v9.99" repeats, and picking the newest
of several was a coin toss;
3. the same patch, by `git patch-id`, which is what survives a reword;
4. the object is gone entirely: if the first 9-12 characters still resolve,
the tail was invented (a short hash padded out to 40 characters) and the
commit the prefix names is the one meant.
Also: only `github.com/wekan/wekan` links are considered, so a link into
wekan/charts or a fork is not matched against this history; and
`--all-sections` (with `--dry-run`) checks the whole file, not only the section
being released - which is how the 106 dead links fixed in the previous commit
were found.
tests/changelogHashFix.test.cjs runs the script over a temporary CHANGELOG and
pins the behaviour: a current link and a tag-only link are untouched, a padded
hash is repointed to the commit its prefix names, an unresolvable hash is
reported rather than guessed, and another repo's link is left alone.
tests/changelogCommitLinks.test.cjs keeps guarding the wiring, with its
ancestry assertion replaced by the reachability one and a note saying why.
Thanks to xet7 !
106 of the 3014 wekan/wekan commit links pointed at nothing. Clicking one
gave GitHub's "This commit does not belong to any branch" 404, and there was
no way to tell from the page which change it had meant.
Three causes, three fixes:
* a rebase, amend or squash after the entry was written (the case
releases/fix-changelog-hashes.sh handles, but only for the section being
released) - the pre-rewrite object is still in the clone, so the commit
with the same subject and author date on this branch is the same commit,
and the link now points there;
* a hash that never existed at all - a 40-character string whose first nine
characters were right and whose tail was invented. Resolved by prefix
where the prefix still resolves, and otherwise by matching the entry's own
text against the commit messages of the release it sits in, one entry at a
time. The "Part 1 / Part 2" links carry no text of their own, so those
were read from the bullet around them: Code of Conduct, Add Header Login,
Updated dependencies, Fix drag drop lists, Red Strings drag-to-connect;
* two entries whose link is the SAME commit as their summary's - the body
repeats the link - which now agree with each other.
Checked the other way round too: a link is only rewritten when its hash is
reachable from no ref in this clone. Two links were reverted after that check
(48ebc5f11, bbc68309a): the commits are reachable from release tags, so those
links were always fine. Every one of the 3014 links now resolves to a commit
in the repository.
The 2019-era hashes that are reachable only from a tag are left as they are -
a tag is pushed, so GitHub serves them.
Thanks to xet7 !
On a board large enough to have scrollbars in both directions, dragging a
card sometimes moved the card, sometimes scrolled the list, sometimes
scrolled the board, and often several of those at once, so the card could
not be dropped where it was aimed.
A board runs three drag-scroll implementations over the same pointer: the
dragscroll library, bound separately to `.board-canvas`, to every swimlane
and to every lane; the `mousedown .board-canvas` lane pan in swimlanes.js;
and jQuery UI sortable, which is the one that is supposed to move the card.
Nothing stood any of them down while a drag was in progress. swimlanes.js
did try, for list drags, but called `dragscroll.reset()` FIRST and removed
the class afterwards - reset() binds to whatever carries the class at that
moment, so the listeners stayed - and it left the canvas tagged anyway. The
lane pan ignored the `nodragscroll` marker the library honours, so pressing
a minicard, which carries that marker exactly because it is the card's drag
handle, started a horizontal pan that ran alongside the card drag.
Now client/lib/boardDragscroll.js suspends drag-scrolling for the whole
drag - class off, THEN reset - and restores exactly the elements it took it
from, so a board route does not gain panning it deliberately turned off.
Every sortable on the board (cards, lists, swimlanes) suspends in start and
resumes in stop, a window mouseup/touchend/dragend restores in case a drag
never reaches its stop handler, and a swimlane re-rendered mid-drag does not
re-arm panning under the pointer. The lane pan now refuses to start on
`.nodragscroll` or on a drag handle, and gives way if a drag begins after
the press. The drag handles themselves are marked `nodragscroll`: a press on
a handle moves the item, it never pans the board.
tests/boardDragScrollDuringDrag.test.cjs pins the order of the two steps,
the restore-what-was-suspended rule, the safety net, the suspend/resume in
every sortable and the two markers; specs/37-card-drag-sort.e2e.js drags a
card sideways on a 12-list board and pins that neither the lane nor the
canvas scrolls while it moves, and that panning is back afterwards.
Thanks to mueschel and xet7 !
Fixes#6558,
The 18:10 run: 267 node suites, 2 failed. All three browsers clean, Mocha 500
passing, FerretDB unit and integration green - and the conformance run has all
FOUR backends answering identically now (see below).
changelogFormat was right, and about MY text: two lines in the entries I wrote
today START with `#`, which markdown renders as a heading and which splits the
version list - the exact trap CLAUDE.md warns about. The issue numbers are
escaped (`\#3138`) and the lines rewrapped so none begins with one.
boardsVisibilitySettings pinned a Yes/No radio in the account-access Save that is
not in that pane: "display the authentication method" is a checkbox with its own
handler, which writes the toggle directly. The guard now checks what that Save
really does - both fields guarded on being on screen, and nothing written when
neither is - and that the display toggle writes itself.
And the news from the same run: sqlite, postgresql, MYSQL and MARIADB all answer
identically on 98 of 100 cases, with ZERO differences. The two exceptions are the
$slice / $elemMatch projections that no backend implements. This morning MySQL
answered "Error 1064" to every filtered query and MariaDB could not create a
collection.
Thanks to xet7 !
The Upcoming section had the CodeQL fixes and the backlog update - their hashes
were corrected after the rebase - but nothing for what came after them:
* the #6533 fix, which is the one users feel: `quave:synced-cron` installs a
process-wide unhandledRejection handler that calls process.exit(1), so one
write losing a race for the SQLite write lock ended the whole server and
systemd restarted it into a busier database. WeKan decides now, and a
transient database error is logged and survived;
* the four guards that pinned an older shape, and the browser test that
reported "class missing" when the board had simply not arrived yet;
* the translations update, as a plain bullet - there is nothing more to say
about it than that it happened.
Checked against the rules: the security subsection keeps "This release", each
later one starts with "and", every summary is one line under 120 characters and
links a commit that exists in this repository, and every commit since the tag is
accounted for except the changelog-only ones.
Thanks to xet7 !
Under a three-browser run WebKit reported "element(s) not found" for
`.board-list-item.has-background-image`, which cannot tell "the board has not
arrived in minimongo yet" from "it arrived without the class". It waits for THIS
board's tile first, found by its id in the seeded title, and only then for the
class - so a future failure names the actual problem instead of both at once.
Thanks to xet7 !
The reporter's 10.45 log shows the startup work landing - "[startup] a startup
step could not finish because the database was busy; WeKan continues" and
"[checklists] Backfilled boardId on 0 row(s); 21 row(s) have no card to take it
from" instead of a 130k-card scan - and then the server dying anyway:
SyncedCron: Fatal error encountered (unhandledRejection): ValidationError:
... sqlite.(*collection).UpdateAll ... database is locked (5) (SQLITE_BUSY)
SyncedCron: Received UNHANDLED_REJECTION signal - cleaning up running jobs
systemd: Main process exited, code=exited, status=1/FAILURE
`quave:synced-cron` installs a process-wide unhandledRejection handler that calls
process.exit(1). So ANY unhandled rejection - including one write losing a race
for the SQLite write lock, which is transient by definition - killed the whole
server, and systemd restarted it into a database made busier by the restart. The
counter reached 73.
server/00processErrors.js takes that decision back. The package is deliberately
polite about it: it only exits `if (process.listenerCount('unhandledRejection')
=== 1)`, i.e. when nothing else has an opinion, so having one is the documented
way to stop it. A transient database error is logged and recorded for Admin
Panel / Problems and WeKan keeps serving; anything else is logged with its full
stack and WeKan still keeps serving, because ending everybody's session over one
rejected promise is a larger failure than the one being reported. An uncaught
EXCEPTION still exits unless it is a transient database error - a process that
threw out of a place nobody handled can hold half-applied state.
The four guards the same run surfaced, each a later assertion:
* boardsVisibilitySettings: the Visibility saves go through
visibilityTextFields(), which does the "is this input on screen" check once
for every field instead of per call site.
* changelogFormat: "the newest release" now means the newest RELEASE (an
Upcoming section may sit above it), the Upcoming section is checked by its
own test, and a release needs at least ONE entry - "more than five" measured
the day's workload rather than the format, and v10.46 carried two fixes.
* leftMenu: /information redirects the FlowRouter way, like /translation.
* narrowWindowLayout: the phone rules carry !important, because the desktop
rule they override is more specific.
Thanks to Nissulya and xet7 !
Fixes#6533,
Every issue in the backlog was checked against GitHub. Ten are closed and are
gone from the list: #3138, #3252, #3276, #3378, #3748, #3828, #4055, #4774,
#5149 and #6511. The "already correct in the current code" category went with
them - it held only #4774 and #4055, both closed - so an empty category does not
sit there looking like work.
#6509 is not a bug report any more, it is "please test FerretDB v1 with MySQL,
MariaDB and SAP HANA", and it is mostly answered: the conformance harness runs
one catalogue of 100 queries against every backend with an image for this
machine, and MariaDB now answers identically to SQLite on 98 of them - the two
exceptions being the $slice / $elemMatch projections that no backend implements.
The entry says what is still open rather than implying otherwise: MySQL's
confirming run is pending (its container lost a port race, since fixed) and SAP
HANA is untested, because its image needs a licence acceptance and a machine with
the memory for it.
And one string was actually translated: Finnish `databaseReportTitle` ->
"Tietokantaongelmat". The other 40 Finnish placeholders are numbers, symbols,
product names (Gantt, LDAP, OAuth2, S3/MinIO, GridFS) and a font name, which ARE
the same in Finnish - the headline "12,254 untranslated strings" is mostly that,
which is worth knowing before anyone sets out to fill them. The search-operator
abbreviations are deliberately left in English: `b:` / `l:` / `s:` are how a
search is TYPED, and translating them changes the syntax for that language, which
is the maintainer's call rather than a wording fix.
Thanks to xet7 !
CodeQL alert #430, js/disabling-certificate-validation, High:
`reqOptions.rejectUnauthorized = false` in the outgoing-webhook path. It is
right, and the switch is gone.
`rejectUnauthorized: false` accepts ANY certificate - including the one a man in
the middle presents - which is the attack the handshake exists to stop. What
#6553 and #6551 actually need is not "verify nothing", it is "this certificate is
legitimate", so that is what WeKan takes now:
WEBHOOK_TLS_CA_CERT the certificate (or CA) to TRUST for outgoing webhooks
MAIL_TLS_CA_CERT the same for the mail server
MAIL_TLS_SERVERNAME the name to verify the mail certificate AGAINST, for the
wildcard-one-level-short case the report describes
Each is the PEM itself or a path to a file holding it. A self-signed certificate
is its own issuer, so naming it here is exactly what makes it valid - and
verification stays ON, the chain is still checked, the hostname is still checked.
A path that cannot be read is not fatal: it logs which setting failed and keeps
the system trust store, rather than stopping mail or webhooks.
The SSRF protections are untouched: the address is still resolved once and
pinned, private ranges are still refused, redirects are still blocked.
CodeQL alert #429, js/incomplete-sanitization, High:
`helper.replace(/\./g, '\\.')` in a test escaped dots only, so a backslash in the
value could change the meaning of the pattern it was spliced into. It uses the
same escape-everything helper tests/testsAreRegistered.test.cjs already has, with
the backslash escaped FIRST.
tests/mailTransportTls.test.cjs now fails if `rejectUnauthorized: false`, that
same assignment, or NODE_TLS_REJECT_UNAUTHORIZED appears in either file - so the
pattern cannot come back quietly - and covers the certificate-from-PEM,
certificate-from-path and unreadable-path cases.
Thanks to GitHub CodeQL code scanning and xet7 !
v10.45 was tagged at 12:11, and the two changes after it went into the file with
no Upcoming section to go into: the #6556 fix landed INSIDE the released v10.45
section, where it says something that is not true - that release does not contain
it - and the test-harness fixes were not recorded at all.
There is an `# Upcoming WeKan ® release` section above v10.45 now, holding both:
the SWC helper fix that 10.45 shipped and the bundler deleted, and the WebKit
navigation retry / conformance container cleanup, with the four guards that
drifted alongside them. The entry that used to open with "…and" reads on its own
now, since it no longer follows the entry it was a continuation of.
Checked against the rules in CLAUDE.md: the file still opens with Platforms and
TODO Later, Upcoming sits between them and the newest release, nothing else is a
heading, every summary is one line under 120 characters and links the commit it
describes, the first subsection says "This release …" and the next "and …", and
the backlog still thanks nobody.
Thanks to xet7 !
Node suites: 267 run, 4 failed - each a LATER assertion in a suite fixed earlier
today, which is how this runner works.
* boardsVisibilitySettings: the template lookup tried only `name='x'`, and this
file writes both quote styles, so a template that is right there "must
exist".
* changelogFormat: my own entry's summary was 121 characters against a 120
limit, and two summaries linked no wekan/wekan commit. One is shortened; the
other two link the FerretDB commit they describe, and the guard accepts a
wekan/FerretDB link now - the fork is where that code lives, and a WeKan hash
for work that is not in this repository points at the wrong change.
* leftMenu: the old /translation URL redirects with
`redirect(FlowRouter.path('setting'))` inside triggersEnter, which is
FlowRouter's own way; the guard wanted a `FlowRouter.go`, which in a trigger
re-enters the router instead of replacing the navigation.
* narrowWindowLayout: "every rule mentioning the bell or the avatar" is three,
not two - the avatar has a second rule that only zeroes its padding. The two
PLACEMENT rules are picked by what they declare.
WebKit: 242 passed, 1 failed - "WebKit encountered an internal error" from
page.goto, which is the browser, not WeKan. openBoard retries five times for
exactly this reason, but a THROW from the navigation escaped the loop, so one
flaky goto failed a test with four attempts left. The navigation is inside the
try now, and the final message says which of the two happened: a board that never
rendered, or a navigation that never succeeded.
Database conformance: postgresql and mysql were reported as "container did not
start" when the truth was `failed to bind host port 127.0.0.1:35432: address
already in use` - a container left behind by an interrupted earlier run. The
script now removes containers named `wekan-conformance-db-*` (its own, never a
`docker compose` stack) before it starts, and a `docker run` that hits a taken
port moves to the next free one and retries instead of declaring the image
broken.
And the news from that same run: MariaDB now answers IDENTICALLY to SQLite on 98
of 100 cases, the two exceptions being the $slice / $elemMatch projections that
no backend implements. It could not create a collection this morning.
Thanks to xet7 !
10.45 still failed in Yandex Browser with the same
"Cannot find module '@swc/helpers/_/_possible_constructor_return'". The previous
fix was in that build and did nothing.
`@swc/helpers` declares `"sideEffects": false`. That is a promise to the bundler
that importing one of its modules changes nothing observable, so an import whose
bindings are never READ may be removed entirely - and
import '@swc/helpers/_/_possible_constructor_return';
is exactly that. Every line of the file was dropped, the package subdirectory was
never pulled into the bundle, and the legacy module tree came out as before. The
fix compiled to nothing and looked like it had been applied.
Every helper is now imported BY NAME and read: they are collected into an array
whose length is written to `window.__wekanSwcHelpers`. That is an observable
effect, so no optimizer may remove the imports that feed it - and the global is
useful on its own, because in a browser that fails this way again it says in one
word whether the helpers reached the bundle at all.
The `_/` path is what is imported, not `esm/`, because `_/` is what the
transform's own imports resolve at runtime; Node's resolver would refuse it (the
package's `exports` map has no `./_/*`), but Meteor's module system resolves the
directory through its package.json `main`, which is what the legacy bundle does.
tests/swcLegacyHelpers.test.cjs now fails if a bare side-effect import comes
back, if any binding is imported without being read, if the array does not end
somewhere observable, or if @swc/helpers stops declaring sideEffects: false (in
which case this reasoning should be revisited rather than silently kept).
Thanks to zubzhaaaw and xet7 !
Fixes#6556,
#6541 (users disappear on 6.09/MongoDB 3.2 with nothing in the log), #6542 (LDAP
import count vs the People list), #6548 (LDAP debug output inside LXC), #6549
(OAuth2 through Rocket.Chat's G Suite SAML app) and #6552 (file-descriptor limit
for Caddy in the snap - snapcraft has no per-app ulimit key). Each with the
reason it is not done, so whoever picks one up starts where this left off.
Thanks to xet7 !
#6536 - "Impersonate User doesn't work": nothing happened, and the log said
"Match error: Expected string, got null". The popup calls the server with the id
from its data context, and when there is none it called with `undefined`, which
arrives as null and fails the argument check. The client does not call at all
without an id now, and says so in the console; the method marks its argument as
audited (this app runs with audit-argument-checks) and then answers a missing id
with "impersonate: a user id is required" instead of "Match failed", which named
neither the method nor what was missing. isImpersonated got the same treatment.
The impersonation itself is unchanged, including that only an admin may do it.
#6538 - "Bad URL in Wekan mail": the invitation mail contained
`http://127.0.0.1/b/...`. Every link WeKan sends is built from ROOT_URL, so when
that is left at localhost the mail goes out with the SENDER's own machine in it -
unusable for everyone who receives it, with nothing failing and no error to look
at. WeKan says it once at startup now, naming the setting and what to set it to,
for an unset ROOT_URL and for one pointing at localhost / 127.0.0.1 / [::1]. A
warning, not a refusal: a single-machine install where localhost IS the address
is perfectly valid.
Thanks to ahlgrimma, BastienGraziani and xet7 !
Fixes#6536,
Fixes#6538.
"Wekan installed but is not visible in menu" - because the snap installs a
SERVER: it starts a daemon and shipped no .desktop file, so nothing appeared in
the menu and it looked like nothing had been installed.
`wekan.open` is that entry. It reads the snap's OWN settings and opens the
address WeKan is actually serving - ROOT_URL when there is one, otherwise
localhost with the configured port - so `snap set wekan port=…` is followed
without anyone editing a desktop file. The URL goes to snapd's xdg-open shim,
which hands it to the session's browser (that is what the `desktop` plug is for);
on a headless install, where there is no session, it prints the address instead
of failing silently.
Thanks to COOKIE-1816 and xet7 !
Fixes#6539.
#6545 - a Keycloak login opened the popup and closed it immediately. The snap's
default for OAUTH2_REQUEST_PERMISSIONS was "'openid profile email'" - the single
quotes are part of the VALUE - so the scope sent to the provider was `'openid` …
`email'`, which no provider knows and a strict one refuses outright. The default
has no quotes now, and WeKan normalises the value anyway
(models/lib/oauth2Scopes.js: strips surrounding quotes, accepts commas or spaces,
drops duplicates), so an install still carrying the old value keeps working
without anyone noticing the difference.
#6546 / #6555 - there was a mongodb-bind-ip but no way to say where WeKan itself
should listen, so it was always Meteor's default of 0.0.0.0 and IPv6 was
unreachable. `snap set wekan bind-ip='::'` listens on IPv6 (and on IPv4 through
it on a dual-stack host); empty keeps today's behaviour exactly.
#6550 - an IPv6 database could not be reached at all: the snap built
`mongodb://$MONGODB_BIND_IP:$PORT/wekan`, and an IPv6 LITERAL has to be
bracketed - `mongodb://::1:27019/` is not a URL and the driver refuses it. The
address is bracketed when it contains a colon, which no hostname and no IPv4
address does; the readiness probe builds its URI the same way, so it can no
longer wait forever on a URL that cannot be parsed.
#6551 - a mail server whose certificate does not match the name it is reached by
("Hostname/IP doesn't match certificate's altnames" - a wildcard one level too
short, an internal CA) could not be used at all, because Meteor builds its
transport from MAIL_URL with no say in it. MAIL_TLS_REJECT_UNAUTHORIZED=false
installs a transport that connects anyway.
#6553 - the same for an outgoing webhook to a server with a self-signed
certificate: WEBHOOK_TLS_REJECT_UNAUTHORIZED=false.
Both are OFF by default, both are per-purpose, and neither is
NODE_TLS_REJECT_UNAUTHORIZED, which would drop certificate checking for
everything the server connects to. The webhook switch changes nothing else: the
connection is still pinned to the resolved address and private ranges are still
refused, so the SSRF protection is untouched - what is given up is only the proof
of who is on the other end, for the one endpoint the operator chose.
tests/mailTransportTls.test.cjs covers the SMTP option building (ports, smtps,
credentials with encoded characters), that only the exact string "false" turns
verification off, that nothing is installed unless it is asked for, and that
neither file reaches for the global switch.
Thanks to lukasjelonek, sysblade, scoopex, 1977er, GuiGuiSoft, marioschulz93 and
xet7 !
Fixes#6545,
Fixes#6546,
Fixes#6550,
Fixes#6551,
Fixes#6553,
Fixes#6555.
#6540 - with `ldap-sync-admin-groups` set to ONE group, every user that logged in
became a WeKan administrator. Two independent causes.
The group query builds `(&(objectclass=group)(member=<the user>))`. The member
clause - the one that says WHOSE groups these are - was left OUT whenever the
user entry had no value for the configured member format, and the search then ran
as `(&(objectclass=group))`, which answers with EVERY group in the directory. So
every user "was in" the admin group. (A login restricted by group let everyone in
for the same reason.) It now answers with NO groups in that case, names the
misconfigured setting in the log, and first tries the usual other spellings of
the same value (dn / objectName / distinguishedName). The clause is added after
the guard, so it cannot be skipped silently again.
The comparison was `LDAP_SYNC_ADMIN_GROUPS.split(',')` matched exactly: "ti,
admins" produced " admins" and matched nothing, an unset value produced [''] -
one empty string - which matched a group whose name the directory did not return,
and Active Directory's case-insensitive names did not match at all. It is one
shared rule now (packages/wekan-ldap/server/adminGroups.js): trimmed,
case-insensitive, multi-valued names flattened, and an EMPTY configured list can
never grant admin. Login and background sync use it, so they cannot drift.
#6537 - "Search All Boards" answered "Server Error", with `$nin needs an array`
in the log. Two calls in the global-search publication were not awaited, so a
PROMISE was handed to Mongo where an array belongs. The `$nin` pair had been
fixed; the `$in` pair on the archived/all branch had not. And both id helpers
were declared with no parameter while their callers passed a userId, so they
asked for the boards of `undefined`: the archived half of a search matched
nothing even when it ran.
#6544 - the REST route to create a checklist item exists (it did not when that
was reported), but every item it created was given `sort: 0`, so a checklist
filled over the API came out in an order nobody chose. It appends now, like the
UI, and an explicit `sort` in the body still wins.
Thanks to karvox, ahlgrimma, frantzstaboeeg and xet7 !
Fixes#6540,
Fixes#6537,
Fixes#6544.
WeKan 10.44 in Yandex Browser died at load with
Cannot find module '@swc/helpers/_/_possible_constructor_return'
An older browser is served `web.browser.legacy`, where SWC compiles classes down
to ES5 and emits imports of its own runtime helpers. Read out of the built
bundle: it contains `link("@swc/helpers/_/_possible_constructor_return", …)` -
the app asks for it - while the module tree beside it holds 22 helper
directories, and that is not one of them. The module system cannot resolve what
the code imports, so the bundle never finishes bootstrapping and nothing renders.
The order of the build is why. Meteor's scanner decides which files of an npm
package to include from the imports it can SEE, and these imports are written by
the SWC transform afterwards - so a helper that only the transform introduces can
be linked without being included. `_call_super` came in through another helper's
relative require and `_possible_constructor_return`, which nothing else requires,
did not. Exactly one was missing, and it was enough.
client/lib/swcHelpers.js imports the ES5 class/inheritance, iteration/spread and
async helper set from ordinary client code, which the scanner does see, and
client/main.js loads it first. Each helper is a few lines, so having the whole
set removes the class of failure rather than this one instance of it - the next
class written slightly differently would otherwise pull in the next helper
nobody imported.
tests/swcLegacyHelpers.test.cjs pins it: the helper from the report is there
along with its transform neighbours, every name resolves to a real subpath of the
installed package (a typo is a silent no-op at build time and the same crash at
runtime), and the file is imported before the styles and the app code.
The modern bundle was never affected - nothing there is transformed that far -
which is why this only showed in one browser.
Thanks to zubzhaaaw and xet7 !
Fixes#6534,
Fixes#6535.
Each pass of the runner reveals the next assertion in a suite, and three of these
were broken by this morning's own fixes.
* adminCheckboxStyle: the checkbox-row ITEM rule covers two shapes now - a
label around a native checkbox, and the `a.flex > .materialCheckBox + span`
that replaced the Attachments ones - so the lookup is on the pair.
* leftMenu: a menu builder may take arguments (`function peopleMenu(user)`,
since multitenancy option D), and the helper matched `()` exactly, so it
dereferenced null.
* narrowWindowLayout: the width block was sliced up to the rule following
`position: absolute` IN it - and the hamburger is `position: static` now, the
last button in the flow rather than pinned to the corner, so that landmark
was gone and the slice ran from the start of the file. It brace-matches the
media query instead.
And two that were true before today:
* boardsVisibilitySettings: "not on screen is left alone" is
`if ($(selector).length)` - the settings are checkboxes read by `is-checked`,
not radios read by value, so `!== undefined` was the old shape.
* changelogFormat: the TODO Later section ends at whatever comes next - the
Upcoming section when there is one, otherwise the newest release. Ending it
at `# v` alone counted every Upcoming entry as a backlog entry, and those DO
thank people.
Thanks to xet7 !
The mysql/mariadb fixes live in wekan/FerretDB main-v1, so the entry's summary
links there. A WeKan hash for work that is not in this repository points at the
wrong change.
Thanks to xet7 !
`.flex` is `display: flex; height: 100%`, which is right for a pane column and
wrong for an anchor that is ONE ITEM of a row: it stretched to the row's height
and pulled the box away from its words. The storage rows and the Backup include
row now place their items - centred, only as tall as they need - whether the item
is a label around a native checkbox or the material one that replaced it.
Thanks to Alishara and xet7 !
The node runner now reports every failure, so each fix reveals the next
assertion in the same suite. Twelve of the twenty are green; these eight failed
on a LATER assertion than before, and one is new.
* themeColorPicker: the category titles are `text-align: start` now, not
`left` - a physical side is what tests/rtl.test.js fails on. The guard asks
for `start` and refuses a physical side.
* adminCheckboxStyle: Attachments left the "panes that use a native checkbox"
list, because that is exactly what #6465 was. It now asserts the REMOVAL,
and the Backup row is three material checkboxes in one row.
* boardsVisibilitySettings: the site-theme picker is a fifth group.
* changelogFormat: the open/close counts are taken on the MARKUP - an entry
that explains the format writes `<details>` in backticks, closed by nothing.
* leftMenu: the heading branch is read without its jade comments; the comment
says a heading has "nothing ... to read a data-id from", and the guard was
failing on the sentence that explains the rule.
* narrowWindowLayout: the drag-handle toggle has TWO rules in the phone block,
so the declarations are looked for across both instead of in the first.
* securityLog: the Problems menu is DATA now (PROBLEMS_MENU + the shared
+leftMenu), so the entry ids are asserted where they live.
* tablePage: "the global-search machinery is gone" is checked on the code with
comments stripped - the comment above the report's config names the
machinery it no longer uses.
Three of these eight were guards reading a COMMENT as if it were code or markup.
That is now a known shape: strip comments before asserting about source.
CLAUDE.md gains the workflow this came from: check the newest logs and fix WHILE
the run is still going - the node suites are the fastest signal (~15s for 260) -
and decide per failure whether the code or the guard is the wrong side, saying
in the test why when it is the guard.
Thanks to xet7 !
WIP-LIMIT POPUP: "the checkbox can not be unchecked and the counter always falls
back to 1". One cause for both. models/lists.js getWipLimit() read the list back
through `ReactiveCache.getList(this._id)`, and on the SERVER that getter is
ASYNC - it returns a promise, and a promise has no `wipLimit` - so the helper
answered 0 for every option, every time. `enableWipLimit` therefore saw value 0
and reset the limit to 1 on every click, and toggled `!enabled` where `enabled`
was always 0, so every click turned the limit ON. The document is `this`, so the
server needs no lookup at all - and a toggle wants the state as it was when the
click happened, not after its own write. The client keeps the lookup: it is what
makes the popup's tick and number follow the change. Apply now also refuses a
limit that is not a usable number instead of sending NaN, which passes
`check(limit, Number)` and dies in the schema with nothing to show the user.
ADMIN PANEL / ATTACHMENTS CHECKBOXES: every checkbox on those panes - Backup's
three, each storage's Enabled and Read, the S3 path-style flag, the avatar-upload
block - drew as a grey rotated RECTANGLE instead of a green tick. They were
native `<input type="checkbox">` styled into WeKan's material checkbox, which
needs the browser to drop its own rendering for `appearance: none`; where it does
not, the geometry applies and the colours do not, which is exactly that
rectangle. They are `.materialCheckBox` divs now, the same markup as the rest of
WeKan, with no native rendering to lose. Two of them could not be unchecked for a
second reason: their state was written as `checked="{{filesystemRead}}"`, a
quoted STRING - and `checked="false"` is checked in HTML.
SIDEBAR CHECKBOX ROWS: "checkboxes and text don't fit well". A row is
`a.flex > i.fa + span`, and `.flex` is only `display: flex` - no alignment, no
gap - so the box glyph touched the first letter of its label and sat on a
different line from it. `align-items: center` and a 6px gap, with a fixed glyph
width so every row's label starts at the same x whether its box is a square or a
tick.
tests/wipLimitPopup.test.cjs and tests/adminAttachmentCheckboxes.test.cjs pin
all of it.
Thanks to Alishara and xet7 !
Fixes#6465,
The reporter's snap, upgraded from 6.09 to 10.44, was in a systemd restart loop
at restart 72, with 8 CPUs at load 7 and SQLITE_BUSY everywhere:
error on boot.js Error [ValidationError]: Failed validation
[msg_update.go:133 ...] [collection.go:191 sqlite.(*collection).UpdateAll]
database is locked (5) (SQLITE_BUSY)
Two things here, each of which made the other worse. (The third, in the FerretDB
fork, is the `_txlock=immediate` default that lets the busy handler cover a
contended write at all.)
1. A TRANSIENT DATABASE ERROR ENDED THE BOOT. Meteor's boot.js exits the process
when a startup callback rejects, systemd restarts it, and the restart re-runs
the same startup work against a database that is busy because the previous
boot was doing it. SQLITE_BUSY means another writer had the lock - nothing is
wrong with the data - so server/00startupResilience.js wraps every startup
callback: a transient database error (the shared classifier's deadlock /
too-many-connections / connection-lost / timeout) is logged, recorded for
Admin Panel / Problems, and swallowed, and the step runs again next start.
Anything else - a full disk, a refused login, a syntax error - is rethrown, so
a real fault still stops WeKan.
2. THE BOARD-ID BACKFILL SCANNED EVERY CARD ON EVERY BOOT. It streamed the whole
Cards collection and issued one multi-update per card - 130,947 of them here,
for each of two collections - and its "anything left to do?" guard could never
go quiet, because a checklist whose card was deleted has no board id to copy
and stays missing one forever. It is driven by the rows that are MISSING the
board id now (normally none), collects their distinct card ids in bounded
chunks, and asks Cards only for those. What cannot be filled from a card is
counted and reported once. The pass is version-gated like the schema upgrade
beside it, so an unchanged version costs one findOne.
tests/sqliteBusyResilience.test.cjs pins both, including that SQLITE_BUSY
classifies as retryable contention while a full disk does not.
Thanks to Nissulya and xet7 !
Fixes#6533,
The new runner ran all 260 suites for the first time: 20 failed. Four were real
defects in the app, the rest were guards pinning a spelling or a design that had
since changed - each of them written when it was true, and none of them run
since, because npm stopped the chain at the first failure.
The app fixes:
* models/lib/themeAccents.js: accentOf('constructor') returned Object's
constructor - a FUNCTION - because a plain lookup answers from
Object.prototype. A board colour named after any prototype member would have
written a function into a stylesheet. Only the map's own keys are themes now.
* RTL: the dependency overlay and its connect handle each set a physical
left/right AFTER the logical property, so the physical one won and both were
pinned to the LTR side in an Arabic or Hebrew layout. The skip link, the
avatar's account badge, the stats value column and the theme-category label
were physical too. All logical now (inset-inline-*, text-align: start/end).
The guards, each corrected to what the code actually does now, with the reason:
* addBoardTileHeight, publicationArgumentGuard: a fixed-size slice
(jade + 3000, source + 1400) that a grown comment pushed the subject out of.
Both now take the whole block they are about.
* adminCheckboxStyle: the Grey Icons feature is gone; asserts its REMOVAL.
* boardsVisibilitySettings: the separator count was pinned at 3; the site-theme
picker group made it 4. Asserts the shape - one Save per section, none for
the picker that applies immediately.
* changelogFormat: "# Upcoming WeKan ® release" is a legitimate heading.
* dbConformanceWiring: the log directory honours WEKAN_LOGDIR now.
* domainTablePage: the default page is the app-wide 10, not 25.
* ferretdbOplogGating: the exec runs under the CPU launcher.
* leftMenu: peopleMenu takes the user (multitenancy option D).
* loginEmailPanesMoved: the board-member restriction is two checkboxes in
Organizations and Teams now; asserted there.
* mobileAllBoardsFit: the bell and the avatar are two rules with variants.
* narrowWindowLayout: the hamburger is the last button in the flow, not pinned.
* rulesApiTriggerNormalize: #6491 made a missing matching field mean "any
value", so the #2674 "can never match" case now matches - and removeMember
iterates assignees, not members.
* securityLog: the new-problems selector moved to models/lib/eventLogProblems.
* tablePage: the Cards report renders the page the SERVER named.
* tenantWiring: the site colour is read in two places; the autorun is the one.
* themeAccents: see above - the guard was right, the code was not.
* translationPane: the "no hand-written table" regex matched the +tablePage
include it also requires.
* uiDensity: admin table cells wrap in one td+th rule now.
* rtl: the dir attribute comes from a helper, `!important` is not part of a
value, and the scanner read CSS COMMENTS as declarations - the comment
explaining why `left: 50%` needs no RTL variant was reported as a violation.
Thanks to xet7 !
test:unit:node was `node tests/a.cjs && node tests/b.cjs && ...`, 260 suites
long. npm's && stops at the FIRST failing suite, so everything after it never
ran - and the summary said nothing about that. Tonight's run printed
"tests:508 fail:1" having skipped about 200 suites: the 01:21 run died at
recoveryReportWiring, which hid sandstormMigrationBridge, which hid whatever is
behind that. One stale guard per full test run, found one at a time.
tests/run-node-suites.cjs replaces the chain:
* it DISCOVERS the suites (tests/*.test.cjs|js and tests/unit/*), so writing
the file is registering it - the 72-suites-in-no-npm-script drift cannot
come back, and neither can a hand-maintained 260-entry line;
* every suite runs even when an earlier one failed, and the failures are
listed together at the end, so one run tells you everything that is broken;
* each suite still runs in its OWN node process, so a suite that throws,
exits or leaves a handle open cannot affect the next one;
* a per-suite timeout (5 min, WEKAN_SUITE_TIMEOUT_MS) means a hanging suite
fails that suite instead of hanging the run;
* output streams live and unchanged, so the " ok - " progress counter still
moves; `--list`, `--bail` and substring filters are there for working on one.
Discovery was compared against the old chain before switching: the same 260
suites, nothing gained and nothing lost.
build.sh reads the runner's own "===== node suites: N run, M failed" line for
the failure count now - exact, instead of guessing from error text - and falls
back to counting the failing suites it has already listed while the run is still
going. The note about the && chain is gone with the chain.
testsAreRegistered now guards the runner rather than a list: every suite on disk
is discovered, the only exclusion is the mocha entry point, every exclusion
carries a reason, and the loop does not stop on failure unless --bail was asked
for.
Thanks to xet7 !
The Chromium run failed 16 of its first 17 tests, each burning the full 60s
timeout inside the fixture: "Test timeout of 60000ms exceeded while setting up
boardPage", with the page showing "Board not found".
My own change from earlier tonight. loginWithToken installed a
page.addInitScript that removes Meteor's three Accounts keys from localStorage,
so the PREVIOUS session cannot resume and race the new login - the
33-board-domains "Unexpected userId after login". But an init script runs on
EVERY navigation of that page. So the goto right after the login, the one that
opens the board the test just logged in FOR, also started with the token
removed: the client was anonymous, the seeded board is private, and the router
answered "Board not found" - five times, 20s each, until openBoard gave up.
The one test that passed in that stretch is "user NOT added to board cannot see
it", which is what being logged out looks like too.
The clear is now ONE-SHOT: the init script does nothing unless a flag
(wekan-e2e-clear-session) is armed in localStorage, and it consumes the flag.
loginWithToken opens /sign-in, arms the flag, reloads - so exactly the one page
load the login happens on has nothing to resume - and every later navigation
keeps the session it just established. It still clears only those three keys;
board view, list widths and the settings specs' own localStorage are untouched.
tests/playwrightLoginSession.test.cjs pins it: the clear must be conditional and
self-disarming, it must not wipe anything else, and arm/reload must come before
the token login.
Thanks to xet7 !
tests/sandstormMigrationBridge.test.cjs failed the whole node suite with "bridge
is released BEFORE the importer binds the port". The ordering in
sandstorm-src/start.js is correct and unchanged; the guard was anchored on
`spawnSync(NODE, [IMPORTER]`, and every spawn in start.js now goes through
cpuExec() - `spawnSync(...cpuExec(NODE, [IMPORTER]), ...)`. indexOf returned -1,
and "stopBridge is before -1" is false. It now anchors on WHAT is spawned
(/spawnSync\([^\n]*\[IMPORTER\]/) and asserts each anchor was found, so a guard
about ordering cannot fail again because of how a process is started.
The run summary was also lying twice about that failure:
- fail:2 for ONE failure. The counter matched both "AssertionError" and the
"throw err" line of the same node dump. It now counts a suite's own
" FAIL - <name>" lines plus one per Error dump, anchored at the start of
the line so the stack frames below it are not failures of their own.
- tests:508 fail:2 read as if all 258 suites had run. npm chains them with
&&, so node stops at the FIRST failing suite and every suite after it never
starts - here about 200 of them. The unit job now says so when it fails.
Thanks to xet7 !
Both legs failed on every release, and not because of a secret or a flake:
##[error]Your build requires a base that this tool does not support (core24).
'base' or 'build-base' in your 'snapcraft.yaml' must be one of 'core',
'core18' or 'core20'.
They were built by snap-qemu with diddlesnaps/snapcraft-multiarch-action, whose
compiled dist/index.js caps at core22 in three independent places, whose build
image has no :core24 tag, and which is unmaintained. WeKan's snapcraft.yaml is
base: core24, so the build died the instant snapcraft read it - after the
version gate passed and QEMU had set up. There is no maintained QEMU multi-arch
snap action that does core24; Canonical's answer for an architecture with no
native runner IS Launchpad remote-build. The evidence, read out of the action's
source rather than off the error string, is in
docs/Design/Autoupdate/Forks/Snap-Core.md.
So: snap-launchpad's matrix is now [ppc64el, s390x, riscv64], and snap-qemu is
deleted rather than disabled.
These two arches once left Launchpad for QEMU because the old remote-build legs
ended in Launchpad state "Stopped" with no snap and then failed at snapcraft
upload ("is not a valid file", exit 64). Today's snap-launchpad job is what
those legs were not: it retries the remote build 3x, requires the .snap file to
exist, and uploads only when it is there. It stays continue-on-error,
fail-fast: false and timeout-minutes: 180, so the price of this path - a
Launchpad queue that can last hours - can neither fail the release nor cancel
another architecture, and each arch publishes the moment it finishes.
It needs LP_CREDENTIALS as well as SNAP_AUTH, which ppc64el and s390x did not
need before. Its first step names either secret when it is unset and decodes
LP_CREDENTIALS, and the remote-build step says by name when Launchpad answers
unauthorized - so a credential problem is one named line, not an ordinary
-looking build failure.
New guard test tests/releaseSnapArches.test.cjs pins it: no snap-qemu job and no
use of the core22-capped action, every platform in snapcraft.yaml built by some
job, the exotic three on Launchpad, the non-blocking options, both secret checks,
and the no-.snap-no-upload rule.
Thanks to xet7 !
The Tests menu had "ALL tests, parallel", "ALL tests, sequential" and, eleven
entries below them, "EVERYTHING (sequential)" - and nothing said what the
difference was. "ALL tests" runs WeKan's own suite only: Mocha, the node unit
suites, the import regression, the node E2E harness and the three browsers.
"EVERYTHING" runs that, then the database conformance run for every database
with a Docker image for this CPU, then all of FerretDB's own tests. Two entries
claiming to be all of them, one of which is not.
Now the everything-run is entry 1 in both build.sh and build.bat, the other two
are named "WeKan's own tests only" and their descriptions end with what they do
NOT cover, and every option says where its logs go.
Every option in the menu now also WRITES those logs: a new one_log helper in
build.sh (:onelog / :tee in build.bat) puts Mocha, the import regression, node
E2E, each Playwright browser, the floating-promises guard and the test counts
into ../log/<datetime>/ beside the whole-suite runs. It reuses WEKAN_LOGDIR when
a larger run set one, so a run stays in ONE directory. Before this, running a
single option left nothing to check afterwards - "check the newest test logs"
only worked for the whole-suite runs.
The .bat prompts stay outside the tee: PowerShell's Tee-Object buffers the
pipeline, so a piped "[y/N]" prompt is never shown.
The guard test pins the order and the wording: entry 1 must be the everything
-run, entries 2 and 3 must not claim to be ALL tests, and both scripts must log
every option.
Thanks to xet7 !
Two entries in build.sh and build.bat.
"Run all FerretDB tests - SEQUENTIAL" runs the FerretDB subdirectory's own
build.sh test-all: unit, vet and the integration suite, one at a time. FerretDB
is expected inside this repo - the "All databases" option clones wekan/FerretDB
when it is not there - and if it is missing this says so with the clone command
rather than failing obscurely.
"EVERYTHING (sequential)" runs the three suites one stage at a time: WeKan's own
tests (which build a fresh bundle and start a server), then the database
conformance run (which builds FerretDB from source and runs one query catalogue
against every database with a Docker image for this CPU), then all of FerretDB's
tests. They share ONE ../log/<datetime>/ directory - passed down as WEKAN_LOGDIR
- so a run that touches three test systems still leaves its logs in one place,
and it ends with a three-line verdict.
Nothing runs concurrently, and the menu text says so: this takes a long time,
and the reason to run it is to find out what is broken, which needs readable
output more than it needs speed.
build.bat runs releases/run-everything.sh rather than reimplementing any of it:
the WeKan stage builds a Meteor bundle and runs a server, which needs a POSIX
shell throughout, and a second implementation would drift. That script is also
the non-interactive way to run it on any platform.
Thanks to xet7 !
WeKan runs on MongoDB or on FerretDB v1 over SQLite, PostgreSQL, MySQL, MariaDB
or SAP HANA, and each answers a broken query, a full disk or a refused login in
its own words. Those words reached an admin as an opaque string: "Error 1064
(42000): You have an error in your SQL syntax" with no way to know which
database said it, that it is FerretDB's bug rather than their data, and that
upgrading FerretDB is the fix.
models/lib/databaseErrors.js is the translation - pure, so it is unit-tested
without a database: a message in, and out comes which database said it, what
kind of problem it is, what it means, what the admin should do, and whether
WeKan can do anything itself. Sixteen rules, every one of them a message a real
database produced in the conformance runs or the documented wording of the
condition: injection refused by FerretDB's SQL guard, the syntax errors of all
four dialects, the two permission failures that stopped MySQL and MariaDB dead
this week, authentication, disk full, connection limits, deadlocks (retried),
lost connections (reconnected), timeouts, duplicate keys (which is an index
doing its job, not a problem), unimplemented operations, and a handler the
binary was not built with.
server/lib/databaseProblems.js records them into a new `database` event stream,
one per problem per minute - a database that is down answers every query - and
watches Meteor._debug, which is where a database error surfaces without wrapping
every call site in the app. Admin Panel / Problems has the pane, and its first
column is the database that said it.
Thanks to xet7 !
They are after the v10.44 release commit, so they open a new Upcoming section
rather than joining the release that was prepared before them.
Thanks to xet7 !
The v10.43 run failed five jobs. Two of them were mine.
build-win64 failed with "wekan-10.43-win64.zip has no bundle/main.js" four
lines after 7-Zip reported writing a 283 MiB archive of 46014 files. The zip
was fine; the check I added last night was not. 7-Zip lists Windows paths with
backslashes, and I searched for them as a REGULAR EXPRESSION - where
"bundle\main.js" means "bundlemain.js", which nothing is called. It would have
failed on every release. The listing is taken once now and searched as a fixed
string, for either separator; both forms were replayed in bash to be sure the
old one matches nothing and the new one matches 7-Zip's own output.
snap-variants did all the work and died on the last push:
"remote: Permission to wekan/wekan-gantt-gpl.git denied to xet7". Its guard
checked that WEKAN_REPO_TOKEN was SET, and set is not the same as allowed - so
a full snap build burned before the token was found wanting. It now asks GitHub
whether the token can push to that variant repository, and skips with a named
reason if it cannot. That does not grant the rights: the maintainer still has
to widen the token, which ../log/workflow/TODO.txt explains.
The other three failures are not the workflow's: ppc64el and s390x cannot be
built by an unmaintained QEMU action that caps at core22, and the variant
pushes need a token that may write those repositories.
Thanks to xet7 !
The conformance report said postgresql and sqlite disagreed on five update
cases and on explain. They did not. $currentDate writes the clock into a field,
every later case in that group returns the whole collection, and the two
backends ran a minute apart - so one server-written timestamp made five cases
"different". Volatile fields are recorded as their type everywhere now, not
only in the case that wrote them. And an explain plan is a different plan on
every engine, keys and all, so comparing its SHAPE reported a difference for
the one thing that cannot be the same; it only has to answer.
MySQL and MariaDB could not run at all: "Access denied for user
'ferretdb'@'%' to database 'conformance'". FerretDB maps each MongoDB database
to a SQL database and CREATES it on demand, which a grant on one database
cannot allow - so this was not a test problem: WeKan on the MySQL/MariaDB
compose files would have failed on its first write to the `wekan` database.
FerretDB connects as root in both compose files and in the conformance run,
with the reason written next to it; the per-database user still exists for
anything else that reads the data.
Playwright 33-board-domains failed again, this time in Chromium: "Unexpected
userId after login". The logout-first fix was not enough, because the race is
earlier - accounts-base reads localStorage at startup and logs the stored user
back in asynchronously, so the sequence could be: page loads, our check sees no
user yet, we log the new user in, and THEN the resume completes and puts the
old user back. The three Accounts keys are removed before any script runs, so
there is nothing to resume, and any login still in flight is waited out before
the state is read.
And a unit test that had been failing since the Admin Panel pane headings
moved: recoveryReportWiring still wanted `titleKey: 'recoveryReportTitle'` in
the report spec, which was removed on purpose - every pane's heading is
rendered once, from its menu entry. The test now pins that, and that the table
does not repeat it.
Thanks to xet7 !
Three things the first run on arm64 found.
The FerretDB it built could not start: `panic: commit.txt value ... !=
vcs.revision value ...`. Go stamps the VCS revision into the binary and
FerretDB's build/version panics when it disagrees with the committed
commit.txt, which only the generator refreshes - so every commit made after the
last refresh built a binary that panicked. Fixed in the fork (its act_build now
regenerates them, as act_dist already did).
Ports: FerretDB listened on 27017, which is where a dev server's database lives
and where the compose files publish FerretDB - so this could not run beside
anything else and, worse, could have pointed the tests at somebody else's
database and rewritten it. It listens on 37017 now and publishes the database
server on 35432, both moved on if busy, both overridable with
WEKAN_CONFORMANCE_PORT and WEKAN_CONFORMANCE_DB_PORT, and its containers are
named per run so a compose stack is never reused or stopped.
And Ctrl-C only killed whatever was in the foreground - a registry lookup, a
sleep - after which the loop carried on and reported the interrupted lookup as
"NO linux/arm64", which is a lie about the image. An interrupt ends the run now,
and the image check has three outcomes rather than two: has it, does not have
it, could not ask. A FerretDB that will not start also prints the last lines of
its log, because the reason is usually one line.
Thanks to xet7 !
It is after the v10.43 release commit, so it opens a new Upcoming section
rather than joining the release that was prepared before it.
Thanks to xet7 !
FerretDB v1 translates one MongoDB query into five different SQL dialects, so
"WeKan starts on MariaDB" says very little. The question that decides whether a
backend can be trusted with a board is whether {n: {$gt: 5}} returns the same
documents, in the same order, as it does on SQLite - and nothing was asking it.
./build.sh -> Tests -> "All databases (sequential)" now does, and build.bat has
the same entry (running the same script through bash rather than a second
implementation that would drift).
It builds FerretDB v1 from source first: the FerretDB subdirectory is cloned
from git@github.com:wekan/FerretDB if it is not there, updated if it is, and
built through its own build.sh, which installs the Go toolchain and the module
dependencies when they are missing. The tests run against THAT binary, not a
downloaded release, so they test the newest code.
Then, for each backend whose database image has a build for THIS CPU - asked of
the registry with docker manifest inspect, so no table here can go stale - it
starts that database, runs the freshly built FerretDB against it, runs the whole
catalogue and stops everything. Sequential, because they all use the same
FerretDB port and a database under test should not be competing with three
others; the menu entry says so.
The catalogue is 100 cases in 15 groups, taken from FerretDB v1's own handler
sources rather than from MongoDB's manual: every query, update and bitwise
operator, the aggregation stages and accumulators, projection and sorting and
paging, count and distinct, indexes and uniqueness, capped collections (which is
how the OpLog exists at all), and the commands whose answers may legitimately
differ. The seed data is deliberately awkward - a null, a missing field, a
negative number, a zero, an empty array, an array of documents - because tidy
data lets a broken translation pass.
Answers are normalised and compared byte for byte against SQLite, so document
ORDER counts. Three cases are compared loosely and say so: an explain plan
differs per engine by definition, and collStats/dbStats are sizes a different
storage engine may answer differently. Two backends failing the same way is
agreement about a limitation; one answering where another fails is a difference.
Everything lands in ../log/<datetime>/ with every other test run: a JSON of what
each backend answered, each backend's container output, the build log, a summary
and db-conformance-report.md. SAP HANA is opt-in behind WEKAN_CONFORMANCE_HANA=1
- amd64-only, ~16 GB of RAM, and SAP's licence to accept - so a menu choice
cannot start it by accident.
Thanks to xet7 !
Every commit since v10.42 was already listed, but three of them were in the
wrong place: entries had been added at the top as they were written, so a new
feature (the FerretDB v1 compose files) and two documentation entries sat under
"This release fixes the following bugs".
CLAUDE.md gives the order - new features, then bug fixes, then developer-facing
changes, then documentation - and one "This release ...:" header followed by
"and ...:" for the rest. The section now reads that way: new features, bugs,
release tooling, the developer-facing test, documentation, and the closing
thanks line. One sentence that said "the pagination fix above" says "below",
because the entry it points at moved.
Thanks to xet7 !
The page listed images and requirements, which left the reader to work out the
pattern themselves. There is a pattern: the databases are not fifteen separate
questions but seven families, and inside a family the answer is the same one.
Each family now says what IT is missing. PostgreSQL-wire and MySQL-wire need no
code at all, only a live run against their dialect - that is where CockroachDB's
s390x image could be won. Enterprise SQL needs a whole backend each, and the
driver is the obstacle only for Db2, whose cgo-only driver cannot be in a
nine-architecture build. Embedded databases need a pure-Go driver before
anything else. Columnar engines pass the driver gate and fail the second one:
WeKan updates single cards constantly, and they are built for the opposite.
Key-value stores would have to have indexes, filters and atomicity implemented
inside FerretDB, which is not a backend but a database. And what already speaks
MongoDB needs no FerretDB at all - the reason for this page is that on ppc64le,
s390x, riscv64 and loong64 that family has no members.
The page opens with all of it in five sentences and one table, because the
conclusion - that the most valuable work is verifying the three backends that
already exist, not writing a fourth - was previously only visible to whoever
read to the end.
Corrections while checking: Oracle DOES have a pure-Go driver (sijms/go-ora
implements the TNS protocol itself), so it is not blocked by cgo the way Db2 is;
and Cassandra belongs in the CPU table, since its image covers ppc64le and
s390x.
Thanks to xet7 !
WeKan runs on every CPU Node.js runs on, and this fork of FerretDB v1 exists
because MongoDB does not publish a server for most of them. Two questions kept
coming back: which databases even have images for those CPUs, and what would be
missing in FerretDB v1 before it could store into one.
docs/Databases/FerretDB/1/Alternatives.md answers both. The architecture table
is read from each registry's own manifest, not from documentation, and it says
plainly what that data shows: PostgreSQL is the only widely-portable database
server - the only one publishing ppc64le, s390x AND riscv64 - MariaDB covers
ppc64le and s390x, MySQL neither, and MongoDB itself and upstream FerretDB 2
are amd64 + arm64 only. This fork's own image covers nine platforms.
What a new backend needs is taken from the code: a pure-Go driver, because the
binaries are built CGO_ENABLED=0 and that is what makes one build serve nine
architectures (which is why Oracle and Db2 are out, despite Db2's ppc64le and
s390x images); the three backends interfaces, none of them stubbable; a
metadata registry; the SQL features the translation actually uses - JSON path
extraction, containment and type inspection, schemas, expression indexes, a
parseable EXPLAIN, and the record-id column that capped collections and
therefore the OpLog are built on; correct MongoDB semantics on top; and a live
integration run, which is the whole difference between the confirmed and
experimental rows.
And the shortcut: a database that speaks the PostgreSQL or MySQL wire protocol
needs no new backend, only an existing one that survives its dialect -
CockroachDB brings s390x that way, for the price of a test run.
Thanks to xet7 !
It was a flat list where the database was a filename prefix, in three spellings
- MongoDB-Driver-System.md, mongodb-avx-qemu.md, MongoDB_OpLog_Enablement.md -
plus FerretDB2-PostgreSQL.md and a ToroDB-PostgreSQL directory. Somebody asking
"how do I run WeKan on PostgreSQL" had to read the prefixes to find out which
files were even about their database.
Now: Migrations, MongoDB, FerretDB (with 1 and 2, because v1 and v2 are
different products with different backends) and ToroDB (with PostgreSQL). Each
file keeps its name minus the redundant prefix - MongoDB/Driver-System.md,
MongoDB/avx-qemu.md - and the old PostgreSQL.md, which was an index of FerretDB
and ToroDB rather than a document about PostgreSQL, becomes the directory's
README.md.
Every directory has a README.md saying what is in it and where to go next,
including FerretDB/1, which had no documentation at all until now although it
is WeKan's default database and just gained four more backends. Links to a
directory point at the DIRECTORY, not at its README.md, because README.md is
what is opened by default.
Every link that pointed into the old paths is updated - in docs, in
docker-compose-ferretdb-v2-postgresql.yml, and in the wekan.fi website - and
the relative links inside the moved files, which gained a directory level and
so needed one more "../". tests/docsDatabases.test.cjs pins the layout, the
READMEs, the directory links, that every relative link resolves, and that
nothing outside CHANGELOG.md still names an old path.
Thanks to xet7 !
docker-compose.yml runs FerretDB v1 on its embedded SQLite. The same FerretDB
can store into PostgreSQL, MySQL, MariaDB or SAP HANA instead, and there was no
compose file for any of them, so those backends were reachable only by editing
the default file by hand.
There is one per backend now, and they differ ONLY in the database: the same
WeKan image, the same ~700 lines of environment and the same comments
explaining them. They are generated from docker-compose.yml rather than copied,
and tests/dockerComposeBackends.test.cjs compares the WeKan service of each
against the default file line for line - a copy drifts on the first setting
somebody adds to one file, and then a user following the PostgreSQL file gets a
differently configured WeKan than the SQLite file gives them.
Each names its own database service, runs FerretDB with the matching --handler,
and says at the top how to start, follow and stop THAT file with docker compose
-f. The images are the newest published: postgres:18, mysql:9, mariadb:12 and
SAP's saplabs/hanaexpress, with the LTS alternative named in a comment where
there is one. PostgreSQL is confirmed working with Meteor 3; MySQL, MariaDB and
SAP HANA are marked experimental in their own headers, because they are.
build.sh and build.bat offer every docker-compose*.yml in the repo now, not
four of the eight, and the test fails if a file exists that neither menu can
start - or if a menu offers a file that does not exist.
Thanks to xet7 !
Four commits since the v10.42 release commit had no entry: the three
release-all.yml changes and the per-tenant report test. They are in Upcoming
now, under "and has the following release-tooling changes:" and "and has the
following developer-facing change:", each linking its own commit, and the
section closes with the usual thanks line.
The pagination entry linked 6eca44c84, which is no longer in main: "Bump
versions for v10.42" landed underneath it and rebased it to c1add5bc5. A link
to a commit that is not in the branch 404s on GitHub, so it is corrected.
And a rule from CLAUDE.md that had been broken in the v10.40 section: a link
whose text was the hash b3537985e. The hash tells a reader nothing they can
act on - the link text says what it points at now. tests/changelogFormat.test.cjs
asserts there are none of those anywhere in the file, so this was a failing
test, not only a style slip.
Thanks to xet7 !
The workflow could fail in ways that left no explanation, or no failure at all:
a version bump that committed nothing, a release published with empty notes, a
zip that exists but holds no bundle/main.js, an upload that reports success and
leaves no asset behind, and a multi-arch image whose manifest is missing a CPU
- which nobody notices until a user on that CPU is told there is no matching
manifest.
Each of those is checked now, and each check says the same three things when it
fails: what was expected, what was actually there, and what to do about it. On
success it prints one "OK: ..." line naming what it verified, so the log shows
what worked and not only what did not.
Every job also ends with a "Job result" step that runs whatever happened, so
the run summary is one line per job - OK, FAILED or CANCELLED - instead of
eighteen logs to open. It is explicitly `shell: bash` because the same step
runs on the Windows and macOS runners.
Thanks to xet7 !
Only three jobs said anything about their secrets. Everywhere else a missing
one surfaced deep inside a tool, as a login that was refused or a checkout that
404'd, with nothing naming the secret - and the docker job did it after the
multi-arch build had already spent half an hour producing an image it could
not push.
Every job that needs a secret now checks it in its FIRST step and names what
is missing. Where the answer is cheap, it also checks that the secret WORKS,
which is the other half of the question: WEKAN_REPO_TOKEN is asked whether it
can push to the repo it is for (HTTP status, then permissions.push), the three
registry credentials are decoded and logged in with, and the base64 ones -
SANDSTORM_KEYRING, LP_CREDENTIALS - must decode to something.
What only the far end can answer is answered where it is used: the Snap Store
uploads now name SNAP_AUTH when they are refused (including the two that are
`uses:` actions, through a step that runs on their failure), and a Launchpad
build that fails with an unauthorized in its log says LP_CREDENTIALS may be
the reason.
Thanks to xet7 !
Admin Panel / Problems is instance-wide, so every report publication and count
method asks for the SITE admin flag user.isAdmin - not canOpenAdminPanel,
which a per-tenant Global Admin passes - and 'problems' is not one of a tenant
admin's tabs. That is what keeps the Boards and Files reports honest now that
they cover the whole instance instead of the admin's own boards.
The one admin list a tenant admin does get, People, is tenant-scoped in all
three places that decide what it shows: the publication, the new page-ids
method and the count all go through peopleScopeSelector, so its pager cannot
count rows its page may not show. Organizations is the same through
orgScopeSelector; Teams is site-admin only and is not one of their panes.
Thanks to xet7 !
snap-qemu (ppc64el, s390x), snap-launchpad (riscv64), snap-variants, ucs and
nextcloud were hard-disabled with `if: ${{ false }}`, so a release never said
anything about them. They all carry continue-on-error: true, so running them
cannot fail a release or hold up another job - the point of running them is to
SEE what they report.
The three guarded jobs - snap-variants, ucs, nextcloud - skip their work while
their secrets are unset. That was a quiet ::notice::; now that they actually
run it is a ::warning::, so the missing secrets are visible in the run summary
instead of buried in a log.
Thanks to xet7 !
Boards Report was empty while the Cards report beside it listed cards from
thousands of boards. It published userBoardIds(this.userId) - the boards the
ADMIN is personally a member of - and on an instance whose admin is not a
board member that is nothing at all. Until the pagination fix the pane hid
this: it rendered every board in minimongo, which the All Boards page had
already put there. The Files report was scoped the same way, to the cards the
admin can access.
Both now cover the whole instance, like Cards, Broken cards, Rules,
Impersonation and Recovery already did. The membership selector was what kept
those two publications honest, so both gained an isAdmin guard at the top -
boardsReport asked only for a logged-in user before - and both count methods
count the same set they page.
Files and Rules also name their page now, like the other reports: opening one
card puts its attachments in minimongo and opening a rules editor puts that
board's rules there, and neither belongs in a report page. The Files page is
sorted by name - paging with no sort is paging over natural order, where a
document can appear on two pages or on none - and that sort has an index now.
Thanks to xet7 !
Admin Panel / People showed the admin on every one of its 578 pages, and
Admin Panel / Problems / Broken cards was one endless page under a pager that
said "1 / 1". Same bug in two shapes: the pane subscribes to a publication
that sends exactly one page, then reads the page back out of minimongo - which
holds far more than the page. The logged-in user's own record is always there
(accounts publishes it), and so is every card of every board the admin has
opened. A plain find(query) cannot tell those apart from the page.
So the server names the page. getPeoplePageIds returns the ids of the People
page with the publication's own selector, sort and window; the Broken cards,
Cards and Boards report publications send the ids of the page they just sent
alongside it. Both panes render that list, in that order, and nothing else.
Every paginated page in WeKan now loads ten rows at a time, from one constant -
TABLE_PAGE_ROWS_PER_PAGE - including the pages that draw a pager of their own:
the reports and event streams, the four People panes, Translation, the search
pages and the archive. docs/Design/Page/Table.md records the rule and its two
deliberate exceptions.
And the counting behind those pagers is index-backed. Every count already asks
the database for a count rather than fetching the rows, but several counted and
paged on unindexed fields: People sorts users newest-first, so on an instance
with 14000 users each page of ten sorted all 14000 first. Broken cards asks an
$or over boardId/swimlaneId/listId/type, which uses no index at all unless
every branch has one. The missing indexes are created at startup by the
idempotent ensureIndex, so an upgrade adds them by itself.
Thanks to xet7 !
Every popup had 10px of empty space above its content that nothing in
popup.css asked for: peopleBody.css and translationBody.css each carried a
bare `.content-wrapper { margin-top: 10px }`, and `.content-wrapper` is not a
settings class - the only element in WeKan with that class is the scrolling
body of a popup. Removed, and the remaining gap is now 6px under the header
plus 12px of content padding = 18px, exactly the 18px beside the first row.
Change Visibility and Change Watch lay their rows out as three left-aligned
columns, so the icons, the names and the descriptions line up and the check
mark of the active row can no longer push its description out of line.
And the second header bar centers its items: symmetric block padding instead
of 7px above and none below, no top margin on the button groups, and a title
that centers on its own text - which is what made the board title sit high.
The sidebar hamburger has equal side margins, so it sits in the middle of the
space between its divider and the end of the bar.
Thanks to xet7 !
From the dev-server log, on a plain attachment upload:
[onAfterUpload] filename hardening failed: TypeError: Cannot read properties
of undefined (reading 'gridFsFileId')
at FileStoreStrategyFactory.getFileStrategy (models/lib/fileStoreStrategy.js:112)
at detectStoredFileMime (models/lib/fileTypeCorrection.js:120)
at FilesCollection.onAfterUpload (models/attachments.server.js:215)
`getFileStrategy` decided the storage like this:
storage = fileObj.versions[versionName].storage;
if (!storage) {
if (fileObj.meta.source == "import" || fileObj.versions[versionName].meta.gridFsFileId) {
A freshly uploaded file has no `meta` on its version - only a GridFS one does -
so that last read threw on EVERY upload. onAfterUpload catches and logs, so the
upload itself succeeded and looked fine, while the two things that hook does were
silently skipped for every new file: detecting the real mime type and correcting
the stored filename from it. `fileObj.meta` can be absent the same way, and so
can the version itself when a caller asks for one this file does not have.
All three reads are defensive now, and the decision is unchanged where the data
is there: a stated storage wins, an explicit argument wins over that, an import
or a GridFS version is still GridFS, and everything else is the filesystem.
tests/fileTypeCorrection.test.cjs replays that decision table, including the
document shape from the log (a version with no meta), a missing version, and no
file object at all - and fails if the unguarded read comes back.
Thanks to xet7 !
The node suites got four assertions further than last time and stopped in the
same file: two more cpuExec expectations still spelled the qemu-wrapped command
as a bare name. cpu-exec resolves it to an absolute path now (qemu-user opens the
file it is given and does not search PATH), so the multi-feature and
WEKAN_QEMU_USER cases assert the resolved path like the other two. The whole
suite was replayed through bash here: all nine cases pass.
WebKit failed 33-board-domains with "Unexpected userId after login" - the admin's
id, when the test had just switched to the non-admin. loginWithToken called
Meteor.loginWithToken over a session that was still logged in as someone else, so
`Meteor.userId()` could remain the OLD user while the new login was in flight,
and the poll could not tell "not landed yet" from "landed on the wrong user": it
timed out and reported the old id as a mismatch. It now logs the previous user
out first and waits for an empty session before logging the new one in, and the
settle deadline is 15s rather than 5s, since a run with three browsers and WebKit
in Docker is slow. Not a product bug - the switch is only done by tests - but a
flaky harness costs a real run every time.
Thanks to xet7 !
Reported after upgrading from WeKan 6 to 10.37/38/40: an attached image is
visible in board view, downloads correctly from the card, and yet the card view
shows an empty white box that cannot be maximized. Cards created AFTER the
upgrade are fine.
The card view does not look at the file; it looks at the flags Meteor-Files
writes at upload time:
if(isImage) img.attachment-thumbnail(src="{{link}}")
...
else span.attachment-thumbnail-text= extension
An attachment that came through a migration can arrive without them - no
isImage, and often no `extension` or `type` either - so it fell through to the
last branch and printed an extension that was not there: an empty box. The same
`link` in the download button worked, because a download needs no flag. The
viewer refused to open it for the same reason.
The kind of a file is derivable, and models/lib/attachmentKind.js is now the one
place that derives it: from the mime type where the document has one (in any of
the fields that have carried it over the years - type, mime, mime-type,
contentType, versions.original.type), and from the file NAME where it does not.
A flag the document states as true is believed, never contradicted; the name is
consulted only when there is no stated type, so a notes.txt that really is
image/png stays an image and does not also become text. The card gallery, the
"add cover" menu and the viewer all ask it, so a thumbnail that renders can also
be opened.
A startup repair (schema upgrade step `attachment-kind-flags`) writes the same
answer back to the documents, so the REST API, the exports and everything else
reading those flags agree with what is on screen. A file whose kind cannot be
told from either its type or its name is counted as unresolved rather than
guessed at.
tests/attachmentKind.test.cjs pins the rules - the reported case first - and the
wiring: 17 kinds derived from a name alone, mime beating a misleading name, a
name that must not be trusted to be a plain name (path separators, query
strings, dotfiles), the type read from each of the fields that carry it, and a
repair that writes exactly what is missing and nothing else.
NOT verified here: no server and no node in this environment, so the rules were
replayed against a model of the module rather than executed. That replay caught
three real mistakes in the first draft (a query string eating the extension, a
name overriding a stated type, and the repair rewriting a document that was
already correct), which are fixed.
Thanks to S0QR2 and xet7 !
Fixes#6532,
First real run of the node suites in "Run ALL tests" (they had never run
anywhere until yesterday's change) and cpuExec.test.cjs failed on this machine -
which is what that change was for. Two real things behind it.
qemu-user does NOT search PATH: it opens the file it is given. cpu-exec passed
the command through unchanged, so a bare name - `cpu-exec --features
x86_64=avx mongod ...`, or `echo` in the test - reached qemu as a relative path
that does not exist and qemu exited 1 having run nothing. On a CPU without the
required feature, the very case this helper exists for, the binary would silently
not run. It resolves the name with `type -P` first (`command -v` would answer
"echo" for a shell builtin, which is exactly the name qemu cannot open), and
hands over an absolute path; an absolute path is passed through as before, and an
unresolvable name still goes to qemu so the error names it.
The test asked for "no qemu" by setting PATH=/usr/bin:/bin - which is only "no
qemu" on a machine without qemu-user installed. This one has /usr/bin/qemu-aarch64
(any host that runs foreign-arch containers does), so the negative case quietly
became the positive one and threw. It builds a PATH containing exactly the tools
cpu-exec uses, symlinked, and nothing else, so the case is the same on every
machine. It also asserts the resolved path now, and gains a test that an absolute
path is handed over untouched.
Verified by replaying the whole suite through bash here (no node in this
environment): direct exec, qemu-wrapped with a resolved path, an absolute path
unchanged, no-features exec, the no-qemu error, and another architecture's
requirements ignored.
Thanks to xet7 !
# An AppImage that packs is not an AppImage that runs. Start it, wait
# 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
- name:Upload them
env:
GH_TOKEN:${{ github.token }}
run:|
set -euo pipefail
if ! ls assets/*.AppImage >/dev/null 2>&1; then
echo "Nothing to upload (no base bundle was published to wrap)."
exit 0
fi
# --repo, because this job does NOT check the repository out - it only
# downloads artifacts - so `gh` has no git remote to infer the repo
# from and fails with "fatal: not a git repository". Every other gh
# call in this workflow already passes it; these two did not, so the
# AppImages were built, smoke-tested and uploaded as artifacts, and
description:'Docker Hub repository under wekanteam/ to publish'
required:true
default:'wekan-gantt-gpl'
type:choice
options:
- wekan-gantt-gpl
- wekan-ondra
version:
description:'WeKan version to publish, without the v (e.g. 10.49). Empty = newest GitHub release.'
required:false
default:''
type:string
also_latest:
description:'Also move the :latest tag of the variant image'
required:false
default:true
type:boolean
permissions:
contents:read
jobs:
retag:
runs-on:ubuntu-latest
steps:
# First step on purpose: a missing or wrong credential is then ONE named
# line at the top of the log, not a failure deep inside a push.
- name:Check DOCKERHUB_AUTH is configured and works
env:
DOCKERHUB_AUTH:${{ secrets.DOCKERHUB_AUTH }}
run:|
set -uo pipefail
if [ -z "${DOCKERHUB_AUTH:-}" ]; then
echo "::error::docker-variant: DOCKERHUB_AUTH is not set - the image cannot be pushed. It is base64 of 'username:token' for docker.io."
exit 1
fi
if ! creds="$(printf '%s' "$DOCKERHUB_AUTH" | base64 -d 2>/dev/null)"; then
echo "::error::docker-variant: DOCKERHUB_AUTH is not valid base64. Set it with: printf '%s' 'user:token' | base64 -w0"
exit 1
fi
case "$creds" in
*:*) ;;
*) echo "::error::docker-variant: DOCKERHUB_AUTH decodes to something without a ':' - it must be base64 of 'username:token'."; exit 1 ;;
esac
user="${creds%%:*}"
token="${creds#*:}"
if ! printf '%s' "$token" | docker login docker.io -u "$user" --password-stdin; then
echo "::error::docker-variant: DOCKERHUB_AUTH was REJECTED by docker.io (login failed as user '$user'). The token is wrong, expired, or lacks push rights for wekanteam/${{ inputs.image }}."
exit 1
fi
echo "docker-variant: DOCKERHUB_AUTH works for docker.io (user '$user')."
- name:Work out which version to publish
id:v
run:|
set -euo pipefail
version="${{ inputs.version }}"
if [ -z "$version" ]; then
# The newest release of wekan/wekan, asked of GitHub rather than guessed.
echo "::warning::v${{ steps.plan.outputs.version }} is missing ${{ steps.plan.outputs.manual }}, which this workflow cannot build: amd64 is the Meteor build, arm64/win64/mac-arm64 each need their own runner, and the Sandstorm .spk is signed. Run release-all.yml for those."
# The six architectures nobody else builds for, repacked from the PUBLISHED
# amd64 bundle - which is what makes this possible without a Meteor build.
extra-arches:
name:${{ matrix.arch }}
needs:plan
if:${{ needs.plan.outputs.extra != '[]' }}
runs-on:ubuntu-24.04
timeout-minutes:300
strategy:
fail-fast:false
matrix:
include:
# The same table as release-all.yml's build-extra-arches, and it has
# to stay the same: `node_arch` is what the Node.js download is
# called (Node says x86 where we say i386, armv7l where we say
# armhf), `ferretdb_arch` is the asset name in the wekan/FerretDB
# release, and `image` must be a base image that PUBLISHES this
# platform - ubuntu:26.04 does not build 386, debian:trixie does.
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
Loaded 100 of 864 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.