Wekan
Open source kanban board application built with Meteor
Alternative to: trello
v11.50
2026-09-05Binaries in these bundles
Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools. Which source has a given CPU varies from release to release - nodejs.org builds some architectures, unofficial-builds others, and the wekan/node-patches build the ones neither of them does - and not every source publishes a checksum. This is what went into this release, and which downloads were checked against a published SHA256.
| Bundle | Binary | From | Version | Checked | SHA256 |
|---|---|---|---|---|---|
| amd64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | be7a7c3a7b83dee0… |
| amd64 | Node.js | nodejs.org | v24.20.0 | verified | 2f2c0da162318f0d… |
| arm64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 8f0b1df0fdff9f26… |
| arm64 | Node.js | nodejs.org | v24.20.0 | verified | 5f4ddab610c1ab20… |
| armhf | FerretDB | wekan/FerretDB | v1.67.0 | verified | 4e35884b7e826048… |
| armhf | Node.js | wekan/node-patches | v24.20.0 | verified | b8ed7065d44f0afe… |
| armv6 | FerretDB | wekan/FerretDB | v1.67.0 | verified | fc657a2929becd70… |
| armv6 | Node.js | wekan/node-patches | v24.20.0 | verified | d5cefa6f8cc4acb1… |
| armv7 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 4e35884b7e826048… |
| armv7 | Node.js | wekan/node-patches | v24.20.0 | verified | c04c81e539347f39… |
| i386 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 6e51a7278f850f82… |
| i386 | Node.js | wekan/node-patches | v24.20.0 | verified | bb44927307460dcf… |
| mac-arm64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 9478ab9f907a4106… |
| mac-arm64 | Node.js | nodejs.org | v24.20.0 | verified | b7bf7707070b950b… |
| mac-x64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 9a052beb1c9d0324… |
| mac-x64 | Node.js | nodejs.org | v24.20.0 | verified | 26fc30891004603d… |
| ppc64le | FerretDB | wekan/FerretDB | v1.67.0 | verified | 8d96ed0624e0a637… |
| ppc64le | Node.js | nodejs.org | v24.20.0 | verified | 341307dcee20d883… |
| riscv64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 791b9294ffb1f48f… |
| riscv64 | Node.js | unofficial-builds.nodejs.org | v24.20.0 | verified | a149c5bf85f98ff1… |
| s390x | FerretDB | wekan/FerretDB | v1.67.0 | verified | 2914f0e00c0d0b5d… |
| s390x | Node.js | nodejs.org | v24.20.0 | verified | ca381121cb5a8d38… |
| win-arm64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | d00ed8f3c5300983… |
| win-arm64 | Node.js | nodejs.org | v24.20.0 | verified | 31c6799744de8a54… |
| win64 | FerretDB | wekan/FerretDB | v1.67.0 | verified | 7e559755fa5b38ce… |
| win64 | Node.js | nodejs.org | v24.20.0 | verified | 6cac9ffbca8f6a47… |
A row saying no checksum published is not a failed check - it is a source that publishes nothing to check against. Those are the ones worth fixing at the source.
v11.50 2026-09-05 WeKan ® release
In short: Undo stops being position-only: it now reads a new universal change history covering every card group, and History is a new view on it, opened from the card, list and swimlane menus. Opening it for real found and fixed a blank table, a row that could not be selected, a panel sized for a menu, and a Restore that handed back the version before the one picked. Copying a list now copies its cards, which an unbound swimlane turned into an empty copy, and Admin Panel / Problems can put back swimlane bindings an older repair cleared. The contribution rules now say which role commits where.
| Platform | Binary | From | Version | SHA256 |
|---|---|---|---|---|
| amd64 | Node.js | nodejs.org | v24.19.0 | 14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 |
| amd64 | FerretDB | wekan/FerretDB | v1.53.0 | eae1f0a8f73bfc979738bfff7284d40fd1bc55de2cc56514721fc155c3624f7d |
| arm64 | Node.js | nodejs.org | v24.19.0 | 01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc |
| arm64 | FerretDB | wekan/FerretDB | v1.53.0 | bdc50caee3ac28495b42d2130b94a042a9dd6d3a38f732cac02b648f36c891da |
| mac-arm64 | Node.js | nodejs.org | v24.19.0 | 3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94 |
| mac-arm64 | FerretDB | wekan/FerretDB | v1.53.0 | cb14ffe93e285903e5a8a9c1821687ddb5b8a979a11c584bf4af534b272c6d3e |
| mac-x64 | Node.js | nodejs.org | v24.19.0 | d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4 |
| mac-x64 | FerretDB | wekan/FerretDB | v1.53.0 | d97dfa9afa60aa05f25384327de82efe7b71d958ed24c1f66618284294a65cd3 |
This release fixes the following SECURITY ISSUES:
Code scanning alerts #442-#446 - CSS string escaping and regular-expression denial of service.
Escape OOXML font names and parse the language registry in linear time. Thanks to GitHub CodeQL and xet7.
The vendored OOXML viewer wrapped document-provided font-family names in CSS quotes and escaped quote characters, but did not escape existing backslashes first. A backslash immediately before a quote could therefore consume the added escape and alter where the CSS string ended. The serializer now escapes backslashes before quotes, matching the safe serializers already present in the same runtime. A regression test pins their order.
Four test suites parsed languages.js with expressions whose repeated escaped-
character alternatives could backtrack inefficiently. They now share a strict
line-by-line parser using fixed delimiters and JSON.parse, with positive,
malformed-input and one-million-character-name coverage. This closes GitHub
CodeQL alerts #442, #443, #444, #445 and #446.
Serving attachments - which files WeKan hands to a browser, and how.
A file WeKan refuses to serve is no longer served by Meteor-Files instead. Thanks to xet7.
A Playwright spec found this, and it found more than it was asking about. stored
HTML is forced to a safe download on the original Meteor-Files route expected
application/octet-stream and got text/html, status 200 - a stored HTML
attachment served inline, which is the stored XSS that
models/lib/fileResponseSafety.js exists to prevent.
That policy module is correct. It was never reached. Meteor-Files calls the
storage strategy’s interceptDownload, and a FALSE return means not handled,
serve it yourself - so Meteor-Files served the file from its stored path, with
its stored Content-Type, and none of WeKan’s headers.
What makes that a bypass rather than a harmless fallback is why the strategy
declines: getReadStream() returns nothing when the file cannot be resolved
INSIDE the storage root, the containment check of
GHSA-4mxf-m8pq-xc9p.
So false means this is not a file WeKan may serve, and handing that same
file to a server with no containment check of its own answers the refusal with
the file.
All three strategies answer 404 and claim the request now, and so does the abstract base, whose body was EMPTY - returning undefined, which Meteor-Files reads exactly as it reads false, so a strategy that forgot to override it failed open too. A file that is genuinely absent gets the same 404 it would have got anyway.
It is deliberately not logged to Admin Panel / Problems: the same path is taken
by an attachment deleted while a link to it survived, which is ordinary use, and
interceptDownload cannot tell the two apart.
and adds the following new features:
Undo - what pressing Ctrl+Z can actually put back.
Dragging a card is recorded, so undoing it does something. Thanks to xet7.
Ctrl+Z after dragging a card did nothing, and never had.
#6478 found that every
trackChange call site guarded on typeof UserPositionHistory !== 'undefined'
against a bare identifier no file imported — it is an ES-module default export,
not a global, so the guard was always false and nothing was recorded. The fix
was applied to the list path and not to the card path, which kept the dead
guard. List moves became undoable, card moves did not, and
docs/Features/Login/Undo/Undo.md said card moves were “already present (now
actually runs)” the whole time.
The import has to be lazy and inside the call: models/userPositionHistory.js
imports models/cards.js, so a top-level import would be a cycle and could
leave the binding undefined depending on evaluation order — most likely why
that file was skipped rather than fixed. The guard is gone from the list path
too; it was harmless there, but it is the shape that turns recording off when
the block is copied somewhere without the import, which is how the card path
stayed dead.
tests/undoRecordsWhatItClaims.test.cjs matters more than the fix, because
undo fails silently — nothing throws when a change is not recorded, the
user just presses Ctrl+Z and nothing happens. It finds the recording sites
rather than listing them and fails when one cannot reach the collection, when
one reintroduces the assumed-global guard, when recording is not wrapped so it
can never fail the move it records, and when a type is recorded that undo()
cannot handle. It also pins the reverse gap — swimlane, checklist and
checklistItem have full undo() cases that nothing records — so that stays a
known follow-up rather than a surprise.
Undo.md now says plainly what is recorded (list moves, list soft-delete and restore, card moves) and what is not: a description, a checklist title, labels, members and dates are written straight to the document with no previous state kept, so there is nothing to restore them from.
The universal change history is built, and Ctrl+Z reads it instead of positions alone. Thanks to xet7.
Phase 1 of the design in
docs/Features/Reports/History/History.md, plus the write half of phase 2.
The store. models/changeHistory.js is the append-only collection: one row
per change, carrying every container id it sits inside — boardId,
swimlaneId, listId, cardId — so a container scope is a plain equality
rather than a join the database cannot do. That is what lets a swimlane’s
history include its lists’ and cards’ rows.
It imports no other model, deliberately. Its predecessor imports Cards,
Lists, Swimlanes and Checklists so its undo() can write to them, and that is
exactly why models/cards.js could not import it, guarded on an assumed global
instead, and recorded nothing for years. A collection that entities must import
cannot import entities. Applying a change back to a document therefore lives in
server/models/changeHistory.js, which nothing imports and which may import
anything.
The rules are pure. models/lib/changeHistoryQuery.js holds the
scope-to-selector translation, the search and the selection normalisation, with
no Meteor and no database — because that is where this feature is either right
or quietly wrong. A scope resolving to the wrong id column shows one board’s
history under another board’s menu; a selection that does not normalise its
input restores the wrong rows. Both are silent. Among the tests: an unknown or
half-given scope is REFUSED rather than widened into a selector that matches
everything.
Undo is now the whole history. changeHistory.undoLast/redoLast replace
the position-only methods, with the keyboard bindings and the tested
pickUndo/pickRedo rule unchanged. One rule covers every change type instead
of a case per action: undo applies previousContent, redo applies
newContent. A restore goes through the same setters an ordinary edit uses, so
validation, hooks and Activities still run, and is itself recorded twice — once
attributed to whoever made the change being restored, once to whoever pressed
Restore.
Recorded so far: card description edits (the previous text is read BEFORE
the write, since afterwards the old value is gone), card moves, list
moves, and list soft-delete/restore, which is what makes undoing a
deleted list work. changeHistory is in the snap’s MERGE_COLLECTIONS, so a
row written on the database copy that is not served is not stranded there.
Not built: the History popup, table, contributor avatars, Restore button and menu items, and recording for the remaining card groups. The methods those screens need exist and are tested; nothing calls them yet. Both design documents now say which half is which — the last time one of them claimed more than the code did, the gap survived for months.
Every card group is recorded, and History is one table opened from every menu. Thanks to xet7.
Phases 3, 5 and 6 of the design, and the viewer of phase 2.
Recording everything, from one place. §5 suggests “a thin, central choke
point … avoids sprinkling calls everywhere”, and
server/models/changeHistoryHooks.js is that: an after.update hook per
collection that diffs the changed fields, plus insert/remove for the
sub-entities. Cards, lists, swimlanes, checklists, checklist items and
comments are covered across every group the design names.
The advantage over editing twenty setters is not brevity, it is coverage: the REST API, the CSV and Trello importers and the rules engine all write through the collection and none call the client setters, so a per-setter rollout would have recorded a description edited in the interface and silently missed the same edit made over the API.
What a hook must not do is record too much, so
models/lib/changeHistoryGroups.js is a table rather than a rule.
modifiedAt and dateLastActivity change on nearly every write and would
bury the changes a person actually made; and the four fields of a move only
mean anything together — reported separately, one drag becomes four rows and
undo puts back a quarter of it. Moves and the list soft delete therefore record
themselves, as one change each.
One table, every scope. §7a: “there is ONE implementation, parametrised by
scope”. client/components/history/historyTable is that one — contributor
pane, search, pagination, row selection, Restore and RTL — and the card, list
and swimlane menus each open it with a different scope. Adding History to a
menu is a menu item and a two-line handler, exactly as the design promises.
tests/historyOneTemplate.test.cjs walks every .jade under client/ and
fails if a second History table is ever defined, because six copies of a table
drift: one gets RTL and the others do not, one gets the search fixed and the
others keep the bug.
Two things the interface needed that were nearly wrong. The scope has to
travel as dataContextIfCurrentDataIsUndefined, because Popup.open’s second
argument is options — a bare object there is ignored, and the popup would
open on the menu’s own data context and show the wrong history. And a popup
without a title key renders with no header and so no close button; this one
reuses the existing history key rather than adding another.
Four new words — Removed, Edited, Moved, Restored — because the Action column is the one a reader must understand. Added the app already had, and every other label reuses the word the card view already uses for that section, so a group reads as Description or Labels in the language the card beside it speaks. That kept this to four keys across 197 locales instead of twenty-six.
Not verified live: there was no Meteor runtime available for this work, so none of it has been opened in a browser. The design asks for each phase to be verified live before the next; both documents now say plainly that this has not happened, and the interface in particular should be treated as unproven.
The History panel - the table every menu opens, and what a running server said about it.
The design document says what was actually opened, and what still has not been. Thanks to xet7.
History.md still said none of this had been exercised in a running WeKan, which stopped being true the moment it was. It now records what was done in a browser rather than what was likely: the card, list and swimlane menus each opening the table with their own scope reaching the template, search narrowing it and reporting no results for a term nothing matched, a contributor’s avatar filtering to that person, and 32 rows paging 1 / 2 with the remainder in sequence.
Nothing new was found in that pass, and that is recorded as plainly as the four faults the first one found - because “it shares a template with something that works” is the reasoning those four faults survived.
History works when it is opened, which is four faults later than it looked. Thanks to xet7.
The entry above says this was written with no Meteor runtime available and should be treated as unproven. It has now been opened, and the pass found four things, three of them fatal to the feature. None was visible in the source, and every one of them looks completely ordinary in a diff.
The table was blank. {{#each row in rows}} binds row as a NAME and
leaves the data context alone - unlike {{#each rows}}, which replaces it - so
{{_id}}, {{contentSummary}} and the rest resolved against the OUTER context.
Nothing errors: the table drew one row of four empty cells and a checkbox with
no id. The contributor pane had the same bug. Every field now goes through its
loop variable.
The row could not be selected. WeKan hides every bare checkbox app-wide -
forms.css: [type="checkbox"] { display: none } - and draws .materialCheckBox
divs instead, so the real one here rendered 0x0. The row was visible, could
never be ticked, and Restore stayed disabled with nothing that could enable it.
The panel was 380px wide. That is a popup’s default, and it left 129px for
the contributor pane and 201px for a four-column table: the search box was
squeezed to 32px and one row had to be scrolled sideways to be read. History is
the same shape as the export panels - two panes, opened from a menu at the edge
of the screen - so it joins them in popup.css and in the full-width list in
client/lib/popupOffset.js, which is the other half that decides where a panel
is put. The rows also scroll in a wrapper now instead of in the table itself:
display: block on a <table> stops the cells sharing column widths, so the
header and the rows under it drift apart.
And a restore was recorded twice - the one fault that is a correct decision
with a consequence. History.md §8.2
says a restore re-applies content through the SAME setters an ordinary edit
uses, so validation, hooks and Activities all still run. The field-diffing
after.update hook is one of those hooks, so it saw the restore’s own write and
recorded it, leaving an Edited row nobody made above the Restored row
describing the same write. Recording, and only recording, is switched off for
the duration of the applier - as an AsyncLocalStorage scope rather than a
module-level flag, because the server handles several requests at once and a
shared boolean set during one user’s restore would have silently swallowed
another user’s edit landing in the same window.
Verified rather than reasoned about, in a browser against a running server: a card renamed through the UI recorded one row with the right group and both values; the card, list and swimlane menus each opened the table with their own scope reaching the template; Restore put the title back and left exactly two rows; search narrowed the table and said no results for a term nothing matched; a contributor’s avatar filtered to that person; and with 32 rows the footer read 1 / 2, the second page held the remaining seven in sequence, and the next arrow disabled itself there.
The History panel leaves the same gap below it as above it. Thanks to xet7.
The panel was pinned 10px below the top of the window and then stopped wherever its contents ended, so a two-row table sat in the top eighth of the screen with the rest of it blank. It now ends 10px above the bottom of the window: measured at 1280x720, 10px on all four sides, a 700px panel, the same in RTL.
Three separate rules were needed, and each was found by measuring rather than by reading the file:
- A height.
popupOffset.jsalready sent a max-height of the right size, and a maximum alone lets a short table stay short. - No margin. The base
.pop-overaddsmargin-top: 6pxas the little gap between a menu and the button that opened it. These panels are not hung off a button - they are pinned to the viewport’s own 10px gutter - so the margin was added to a position that was already final. Every full-width panel sat 16px from the top while its own comment said 10, and on History, which now states a height, it pushed 6px past the bottom as well: 16px above against 4px below. The export panels have the same contract and lose it too. - Fixed positioning. Every other popup is absolute in DOCUMENT coordinates, which is right for a menu that should travel with its button and wrong for a box sized from the viewport. Opened on a page scrolled 53px down and then scrolled back, the panel stayed 63px below the window with 43px of itself past the bottom. It is positioned from the viewport now, the way the Admin edit popups already are.
The height then had to reach the table or the blank space would only have moved
indoors, so the wrappers between the shell and the template pass it down, the
rows take what the controls leave, and they scroll inside the panel instead of
being capped at 55vh. With 40 rows the panel stays 700px and the table scrolls
within it.
And it fills the screen at phone widths, so the scrollbar is at the foot there too. Thanks to xet7.
Above 801px the panel already ended the same 10px from the bottom that it starts from the top. Below that, where every popup becomes a full-screen sheet, it did not: the horizontal scrollbar sat halfway up the panel with blank space beneath it, because nothing carried the sheet’s height down to the rows.
Measured at 375x812, three things were wrong, and each of them is invisible in a diff:
- The chain was desktop-only. The rules passing the height from the shell to
the template were written inside
@media (min-width: 801px)- backwards, since above that width the panel is given a height directly and below it the sheet is the only thing that needs the help. - The percentages had nothing to resolve against.
.content-containerstopped at 588px, capped bymax-height: calc(70vh + 20px), whose usual override does not reach a sheet;.contentcollapsed to 10px, itsheight: calc(100% - 20px)resolving to the padding alone. The chain is flex now, which needs no parent height. - Two caps ate the edges.
max-height: 90vhleft a tenth of the screen empty under the sheet, and the*reset being content-box put the 1px border outside the stated height - 10px above against 8px below.
Verified in a browser at 375x812, 768x1024, 886x711 and 1280x720: under 801px the sheet fills the screen, above it the gaps are 10px on all four sides, and in every one the scrollbar is at the foot of the panel.
A scrollbar with nothing to scroll, and a band of empty space above the search box. Thanks to xet7.
Both faults were inside the panel, which is why measuring its frame found neither - the gaps around it were right the whole time.
The empty band. .content-container holds the popup STACK, one entry per
open menu, with the ones you are not looking at collapsed by
.content.no-height { height: 0 }. Making the container a flex column turned
those into flex items and the growth rule reached all of them: flex: 1 1 auto
says grow, and height: 0 is only the basis it grows from. The card menu
History was opened over stayed in the layout and took 336px of a 925px window.
The scrollbar. width: 100% on the table was wrong at both ends, in
opposite directions. Wide: WeKan’s global table, td, th rule gives the table a
border-inline-start: 1px - the vertical lines between columns - and the *
reset being content-box added it outside the 100%. Columns summing to 993.047 in
993.047 of space, border box 994.047: one pixel of nothing, drawing a bar across
the foot of the panel at every width the table fitted. Narrow: the table stayed
at 100% while its columns needed 436px, so they spilled out of it and the scroll
box - which measures the table, not its spill - offered a 1px bar to reach
171px of content. It is width: max-content with min-width: 100% now, so the
table grows to what the columns want and fills the panel when they want less.
The 10px gutter stays desktop-only: below 801px, and in mobile mode at any width, a popup is a full-screen sheet flush to all four edges. The height chain is deliberately not in a media query, for the opposite reason - the sheet still has to pass its height down, or the scrollbar ends up halfway up the panel again. Measured in desktop mode at 886x711: 10px on all four sides, no horizontal scrollbar, 65px above the search box - the header and nothing else. At 375x812: edge to edge, with 169px of real horizontal scrolling.
Restore puts back the version you picked, not the one before it. Thanks to xet7.
Reported as “when I try to restore card description from card history, it restores wrong history, that I did not select” - and the selection was never wrong. The server restored exactly the row whose checkbox was ticked. It applied the wrong half of it.
A row carries two contents, before and after. The table shows the AFTER - History.md §7: the content column holds “the new text” - and Restore applied the BEFORE, because it reused the undo path. So choosing the row that displayed the description you wanted handed you the description from the row above it, and choosing rows one after another walked backwards through the history instead of moving through it.
Restore has a direction of its own now, and the rule is one sentence: the row a
reader picks and the value they get are the same thing. Undo is deliberately the
other way round - Ctrl+Z reverses your own last change - and is unchanged. The
two agree only when the row you pick happens to be the last one, which is why
this survived the first live pass: the title restored there WAS the most recent
change, so both directions gave the same answer.
The rows a restore appends were wrong in the same way - they repeated the restored row’s own before and after, which describes a different change. The live value is read before the write now and recorded as what was displaced.
Verified against a running server with three description versions, FIRST, SECOND, THIRD, and THIRD current: picking the row showing FIRST set the description to FIRST and logged THIRD → FIRST, then picking SECOND set it to SECOND and logged FIRST → SECOND. Before this, picking FIRST emptied the description.
and fixes the following bugs:
API usage report - recording calls after their responses finish.
API usage counts now pass EventLog schema validation. Thanks to xet7.
The API middleware accumulated calls correctly, but every timed flush failed
with api is not allowed by the schema in eventlog updateAsync. Its summary
identity writes api and apiUserId, and the shared fold writes the normalized
ipv4 or ipv6 address, while the attached EventLog schema declared none of
those fields. Collection2 rejected the upsert selector at api, so the report
could never receive a row. All four fields are now declared. Regression coverage
checks every API/fold field against the schema so another report column cannot
be wired end to end yet rejected only when its first live event arrives.
Minicards - what dragging the card title does.
The minicard title drags the card or drag-scrolls the board. Thanks to xet7.
Direct title editing on the minicard intercepted the largest natural drag surface. It is now commented out. With drag handles disabled, dragging the title moves the minicard along with the rest of the card. With drag handles enabled, only the handle moves the card, while dragging the title pans the board. Card sorting continues to work in both modes: a browser regression drags the title with handles disabled and the visible handle with them enabled, then checks the persisted card order. Focused negative coverage also ensures the title does not become an inline-editor trigger again.
The minicard drag handle no longer has a grey background. Thanks to xet7.
On touch devices the enlarged drag target looked like a separate grey button. Its background is now transparent, leaving only the drag icon visible, while the target remains directly below the minicard menu and keeps its full finger-sized area. The focused regression test pins the transparent background, the menu-and-handle ordering, the shared trailing edge and the absence of the old grey or tinted background.
The minicard drag icon lines up below its menu. Thanks to xet7.
The touch target was in the correct trailing-edge column, but Font Awesome’s four-way arrow has uneven side bearings and made the visible icon look too far toward the edge. The glyph now moves inward independently while its 44-pixel touch target stays in place. The handle also explicitly suppresses borders and shadows and gives its transparent background priority, preventing a mobile rule from drawing a grey block below the icon. The focused regression test covers the logical, RTL-safe alignment and every background layer.
Mobile Mode uses one aligned minicard control column in every browser. Thanks to xet7.
The enlarged, aligned menu-and-handle column was restricted to coarse pointers. A desktop browser switched with Toggle between Desktop and Mobile Mode has a mouse pointer, so it kept the compact desktop drag handle and placed its center farther toward the minicard edge than the menu center. Mobile Mode now owns the layout regardless of pointer type: both controls have the same trailing inset and width, which gives them exactly the same horizontal center on phone and desktop browsers and mirrors the column in RTL. Desktop Mode retains its compact handle. The regression test calculates and compares the two centers and rejects any pointer-type media query that could split the explicit mode again.
iPhone Safari no longer paints the minicard drag target grey. Thanks to xet7.
iPhone Safari still drew a grey rectangle over the full drag target in Mobile Mode even though its CSS background was transparent; Desktop Mode’s compact handle did not expose the problem. The Mobile Mode target now inherits the minicard’s actual background, so it blends into white, coloured and hovered cards, and explicitly disables WebKit’s tap highlight. Its pseudo-elements, border and shadow are also pinned to paint nothing. The icon, aligned control column and 44-pixel touch target are unchanged. Regression coverage keeps these Safari-specific paint guards scoped to Mobile Mode.
Mobile card and swimlane drag handles now keep their desktop alignment. Thanks to xet7.
The Mobile Mode minicard arrow was shifted six pixels away from the shared menu-and-handle control center. Mobile font scaling enlarged that error, leaving the arrow visibly to the side of the menu bars even though their touch targets had equal width. The shift is gone, so both glyph centers have the same x coordinate in every browser and direction. Swimlanes also no longer substitute a larger, separately positioned handle on touch devices; one compact logical-position handle is shared by Desktop and Mobile Modes on every device. A live Chromium regression compares the actual glyph centers and verifies the swimlane handle’s x coordinate is unchanged when switching modes. Focused positive and negative source tests reject either device-specific positioning variant.
Swimlanes - which swimlane a list belongs to, and what travels with it.
Opening a Mobile Mode list no longer empties the other swimlanes. Thanks to xet7.
Mobile Mode stored one globally selected list, and every swimlane guarded its
compact list rows with unless currentList. Opening List 1 at Swimlane 2
therefore expanded that list correctly but hid every list in Swimlanes 1 and 3.
The guard now asks whether the selected list belongs to this swimlane: its own
swimlane renders the expanded list while every other swimlane retains its
compact rows. Board-wide lists still expand in every swimlane by design. A live
Chromium regression against the port-3000 application seeds two swimlanes,
opens the second one’s list and verifies the first one’s list remains visible;
focused positive and negative source tests pin the template scope as well.
Opening a Mobile Mode list no longer resizes lists in other swimlanes. Thanks to xet7.
The compact list rows in every swimlane consulted the board’s global selected-list state when choosing their header controls. Opening List 1 at Swimlane 2 therefore made the still-visible rows in Swimlanes 1 and 3 switch to the expanded header shape, changing their height even though neither swimlane had been selected. A list header now switches shape only when its own ID is selected, so every other swimlane keeps the same controls and row heights. The live Chromium regression records every compact row height before opening the second swimlane’s list and verifies the dimensions are unchanged afterward; focused positive and negative source coverage pins the ID scope.
Opening a Mobile Mode list no longer enlarges the top header. Thanks to xet7.
Selecting List 1 at Swimlane 2 inserted the names of every board list into the quick-access header. On a narrow screen that list navigation consumed another row, made the blue top bar taller and moved the board content down. Mobile Mode already presents every list as a selectable row inside its swimlane, so the duplicate header list has been removed. The live Chromium regression verifies that the selected list’s name is absent from the top bar and that the bar has exactly the same height before and after the list opens; focused negative coverage prevents the conditional list from returning to the header template.
Copying a list copies its cards, into a new list. Thanks to xet7.
List.copy(boardId, swimlaneId) carried both of the faults the List.move
fix in v11.49 removes, and the copy was the more visible of the two: it
produced an empty list.
const oldSwimlaneId = this.swimlaneId || null;
...
const cards = await ReactiveCache.getCards({
swimlaneId: oldSwimlaneId, listId: oldId, archived: false });
A list that is not bound to a swimlane - an empty or missing swimlaneId,
which is what every list on a board predating per-swimlane lists still has,
and what #6515 left behind on
boards opened before it - turns that into swimlaneId: null, so the
selector asks for cards that have NO swimlane. The cards of such a list
carry the real swimlaneIds of the swimlanes they are in, so it matched
nothing and the copy came out with no cards at all. Even for a bound list
the filter could only ever remove cards that are in the list being copied.
A list is the unit of a copy, so every card in it travels, exactly as in
List.move.
The second fault is the #6670
shape exactly. copy() searched the target board for a list with this title
to reuse, without first asking whether the target board IS this list’s own
board - and on a same-board copy that search finds THIS LIST. _id became
the original, so the “copy” wrote the cards back into the source list,
doubling them, and returned the source list’s id, which
POST /api/boards/:boardId/lists/:listId/copy then repositioned: the user
asked to copy a list and got the original moved with twice the cards.
Reusing a same-titled list is only meaningful across boards, so a same-board
copy is now always a new list, the way Swimlane.copy already creates one.
Fixing the first fault raises a question that could not come up while the
copy was empty: where the cards land. The REST endpoint’s own default is a
copy on the same board with no toSwimlaneId, and pinning every card to
“no swimlane” would dump the cards of three swimlanes into none - so when no
swimlane is asked for and the copy stays on the same board, each card keeps
the swimlane it is in and the duplicate looks like the original. Across
boards it cannot: the source card’s swimlaneId belongs to the OTHER board
and would arrive orphaned, so those cards take the copy’s own swimlane.
The decision lives in models/lib/listCopyPlan.js, the twin of
models/lib/listMovePlan.js, where it is unit-tested without a database;
models/lists.js applies it. tests/listCopySwimlane.test.cjs pins the
same-board copy and the copy of a board-wide list, and its negative tests
require that the card selector never scopes by swimlane, that a same-board
copy is never a merge even when a same-titled list exists, that a
cross-board copy never keeps a swimlaneId from the source board, and that
models/lists.js compares the boards before it looks a list up by title.
tests/listMoveSwimlane.test.cjs is corrected while its sibling is written:
two of its source scans took their offsets from the un-stripped file and
sliced the comment-stripped one, which worked by accident and slid off
List.move as soon as anything above it changed. Both offsets and the slice
now come from the same string, and every assertion is kept.
Admin Panel / Problems can put back the swimlane a list lost. Thanks to TawsTm and xet7.
Boards opened under the versions before
#6515 had every per-swimlane
list un-bound automatically: the board data-repair treated any list with a
swimlaneId as #6484
corruption and cleared it, and a per-swimlane list is indistinguishable from
a corrupted board-wide one at the data level. #6515 stopped it, but nothing
put the bindings back, so those lists still render under every swimlane and
deleting one from a swimlane deletes the only list document there is.
The old value turns out to be recoverable rather than guessable. The
clearing went through Lists.direct.updateAsync, which bypasses collection
hooks, so it only ever touched the list document - while the binding each
list was CREATED with is recorded in a different collection:
// models/lists.js - Lists.after.insert -> trackOriginalPosition()
originalSwimlaneId: this.swimlaneId || null,
if (!existingHistory) { PositionHistory.insertAsync(document); }
That insert is insert-ONLY, written once at creation and never overwritten, so it survived untouched for every list created since list position tracking landed in October 2025.
Admin Panel / Problems / Summary now detects this the way it detects broken
cards - Lists missing their swimlane N, with a Restore button beside it -
and restoring puts each list back in the swimlane its own record names.
Every rule in it is a reason to SKIP, because here doing nothing is better
than doing something wrong: a list that already has a swimlaneId is never
touched, so the repair is idempotent and cannot undo a binding an admin has
set by hand since; a list with no record, or one recorded as board-wide,
stays board-wide; and a swimlane that has since been deleted is not
resurrected, nor is one on another board accepted, because either would hide
the list in every swimlane rather than show it in one.
Nothing is inferred from the cards, and a test pins that the planner cannot grow a use for them. Inference is the obvious idea and it is wrong: on a board whose second swimlane is new every card is still in the first one, so it would bind every list to swimlane 1 and hide them from the others - which is #6484 again, the bug the clearing existed to fix.
Detection is read-only and swallows its own errors, since the Problems page
polls it every thirty seconds and a detection that throws would take the
other problems on that page with it. The repair writes swimlaneId and
nothing else, through .direct, one update per swimlane rather than one per
list.
and states the rules a contribution is judged by:
Contributing - who commits where, and who gets named for the work.
The instruction files say where the WeKan checkout is on each operating system. Thanks to xet7.
“The WeKan repo” meant “wherever you happen to be”, and an agent given a task
in the wrong directory had nothing to check itself against. It is a fixed place
per operating system now - ~/repos/wekan on Linux, ~/Documents/repos/wekan
on macOS, Downloads\repos\wekan on Windows - along with the companion
repositories under .tools/, each on its own default branch, which is not
always called main.
This commit also said that commits go directly on main, full stop, which is
true of the maintainer and wrong for everybody else. The entry below is the
correction, in this same release: a contributor works on a branch in their own
fork and opens a pull request. The rule as it stands is the two-role table, not
this half of it.
The contribution rules say which role commits on which branch, and where the checkout is. Thanks to xet7.
Two things an agent had to infer, and could infer wrongly. WHICH BRANCH: the
maintainer commits directly on main - never a feature branch, never a
pull request - and a contributor works on a branch in their own fork and
opens a pull request, never committing to main. Both halves are given
their reason, because a rule with a reason survives a tool that offers
something else: releases/release-all.sh cuts a release from whatever is on
main, so work parked on a branch misses it; and nobody but the maintainer
commits to main in wekan/wekan, so a change from anyone else arrives as a
pull request, which is also the only place it can be discussed first.
WHERE: the checkout is at a fixed place per operating system -
~/repos/wekan on Linux, ~/Documents/repos/wekan on macOS,
Downloads\repos\wekan on Windows - so “the WeKan repo” no longer means
“wherever you happen to be”.
The never-push boundary is unchanged and covers both roles: an AI makes the
local commit and stops, whether that commit is on main or on a
contributor’s branch. Pushing it, opening the pull request and every release
step are the human’s.
An AI is credited when it is the contributor, and never when it only helped one. Thanks to xet7.
Never attribute a commit to an AI was one rule where there are two, and it gave the wrong answer to half the cases it met. The test is whether a person is behind the change:
- A human’s AI is invisible. The maintainer or a contributor using an
assistant is the author; the tool appears nowhere - no
Co-Authored-By:trailer, no Generated with, no model name in the commit, the pull-request body or the CHANGELOG. - An AI that raised the pull request itself is the contributor, and is named. GitHub CodeQL filing a fix for something it found, Copilot Autofix, Dependabot: nobody wrote those, so crediting a human would be false and crediting nobody would leave the change unattributed.
So the same word - Copilot - is forbidden in one commit and required in
another, and the files now say which is which. The second half is not new
practice: the dependency sections have always closed with
Thanks to dependabot. and the Hall of Fame has always named GitHub CodeQL
as a reporter. Those were the rule being followed without being written down.
CODE_OF_CONDUCT.md carries the same rule in its own
plain register, since it is where a contributor looks first.
The maintainer's AI is credited on the sponsors page, and only there. Thanks to xet7.
Saying a human’s AI is invisible read as “never acknowledged anywhere”, and a rule that omits its own exception invites somebody to add the credit back where it was removed from. It is acknowledged, once, at wekan.fi/sponsors, under “AI donated by. All code and PRs verified by xet7”, where Claude, Codex and GitHub Copilot are listed alongside the people and companies that donate hosting, servers, grants and testing.
That is the whole of the credit, and it is where it is because attributing it per commit put the same fact on thousands of lines and drowned out the humans the entries exist to name. Acknowledging it anywhere else is not extra politeness; it undoes that.
and has the following developer-tooling fixes:
The build tree - which directory a build writes to, and what is not source.
build.sh raises the open-file limit, so mongod does not abort mid test run. Thanks to xet7.
A full Playwright run died thirteen minutes in, and every spec after it reported
MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:3001 - which reads
like the test database was never started. It was. It started, said what was
wrong with it in the same breath, and was ignored: Soft rlimits for open file
descriptors too low, currentValue 256, recommendedMinimum 64000. Thirteen
minutes later it ran out of them and WiredTiger panicked.
macOS starts a shell with a soft limit of 256, so every run of the browser
suites was a race between finishing and running out of descriptors, with the
cause landing 10,000 lines away in a different log. ensure_open_files runs on
every invocation beside the inotify check. It needs no root - the hard limit is
normally unlimited - and it sets the SOFT limit only, because plain ulimit -n
sets both and lowering a hard limit is irreversible.
Three permanently red test suites now pass, two of them macOS faults. Thanks to xet7.
Each was a different fault rather than a stale assertion.
database-autopick restores MongoDB’s data-file mtimes with stat -c %Y and
touch -d @EPOCH, which are GNU. On macOS the stat fails, every file is skipped
by the || continue beside it, and the restore returns having done nothing -
silently, and only there. It tries the BSD spelling now.
provenance-table.sh opened with shopt -s nullglob globstar, and globstar
arrived in bash 4 while macOS ships bash 3.2 as /bin/bash: the shopt failed
and set -e took the script with it. The default file list is a single find
now. Then the empty case failed too, because bash 3.2 calls an empty array an
unbound variable under set -u where 4.4 does not.
fill-translations.mjs --list was TRUNCATED whenever stdout was a pipe: it
prints 128 KB with console.log and then calls process.exit, which cuts off
whatever has not drained. A pipe delivered 65,510 bytes - valid-looking JSON
stopping mid-key, with status 0. Redirecting to a file worked, because that is
synchronous, so this only bit anything reading the output programmatically -
including the translation workflow itself.
The last two red suites: a missing utility, and a count of another repository. Thanks to xet7.
bundle-smoke-boot.sh runs the bundle under timeout, which is GNU coreutils
and is not on macOS. It died with timeout: command not found and exit 127 -
which the script’s own checks then read as the bundle exited without reaching
its database, reporting a startup failure for a missing utility. It uses
timeout, or gtimeout, or a shell fallback now.
The Hall of Fame comparison demanded at least 50 *bleed directories in the
wekan.fi checkout as a sanity check. That number counts what was published when
the line was written, so a checkout two months old has fewer and the suite went
red over the state of another repository. A stale checkout cannot cause a false
failure there - it only makes the check smaller - so what it asks now is whether
the directory is the Hall of Fame at all.
Attachments are readable again when the storage root is a symlink. Thanks to xet7.
FileStoreStrategyFilesystem builds its candidate paths by joining names onto
the storage root AS WRITTEN, then checked each one against that root with every
symlink RESOLVED. Those are the same string only when nothing in the path is a
symlink. On macOS os.tmpdir() is /var/folders/..., a symlink to
/private/var/folders/..., so every candidate failed containment before it was
looked at. A deployment whose data directory is a symlink has the same fault on
Linux.
The security property is untouched, because it was never the lexical check that carried it: what a caller must not do is reach a file outside the root, and that is decided by resolving the candidate and requiring the result to be inside the resolved root.
build.sh raises the open-file limit, so mongod does not abort mid test run. Thanks to xet7.
A full Playwright run died thirteen minutes in, and every spec after it
reported MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:3001 -
which reads like the test database was never started. It was. It started, said
what was wrong with it in the same breath, and was ignored:
"Soft rlimits for open file descriptors too low"
currentValue: 256, recommendedMinimum: 64000
Thirteen minutes later it ran out of them - Too many open files accepting
connections, then opendir on its own journal, then WT_PANIC: WiredTiger library panic and Abort trap: 6. macOS starts a shell with a soft limit of
256, build.sh never raised it, and so every run of the browser suites was a
race between finishing and running out of descriptors, with the cause landing
10,000 lines away in a different log in a form that points at the wrong thing.
ensure_open_files runs on every invocation beside the inotify check, for the
same reason: a limit that is too low breaks a later step with an error that
does not name it. Unlike that one it needs no root - the hard limit is normally
unlimited, so the process raises its own soft limit and mongod, the bundle
server and the browsers inherit it. It sets the SOFT limit explicitly, because
plain ulimit -n sets both and lowering a hard limit is irreversible; and it
clamps to the hard limit and steps down from there, because asking for more
fails outright rather than clamping.
Commit links in this file are repointed when a rebase makes them stale. Thanks to xet7.
A rebase rewrites hashes, and every <summary> here carries one in its href.
The links that had gone stale were repointed, which is the same repair
build.sh’s pull and push both run - a stale link is not a local annoyance,
it is a 404 for everyone who opens the release notes.
The History table formats dates with the helper WeKan actually has. Thanks to xet7.
The new table opened with import moment from 'moment', and that one line
broke the client build outright:
ERROR in ./client/components/history/historyTable.js
x Module not found: Can't resolve 'moment'
moment was removed from WeKan and replaced with native Date helpers -
imports/i18n/moment.js says so in its first line - so it is not a dependency
and nothing resolves it. The import is now formatDateTime from
/imports/lib/dateUtils, which is what the rest of the app already uses.
What is worth keeping from this is how it was found. The line is the most
ordinary-looking one in the file, and no amount of rereading the diff would
have shown it, because what was wrong was somewhere else entirely: the
dependency list. tests/importsResolve.test.cjs now resolves every bare
import in client/, models/, imports/ and server/ against the real
node_modules, which is the same question the bundler asks and takes under a
second instead of a two-minute build. It also pins that moment stays gone, and
that the set of packages imported without being declared in package.json -
body-parser and mime-types, both of which predate this - cannot grow.
Every ignore file agrees on what is not source, and the two build directories are explained. Thanks to xet7.
There are two build directories one character apart, and nothing said which
was which. _build/ is rspack’s handoff: it compiles the app into
_build/main-prod/ and Meteor then reads server-meteor.js and
client-meteor.js from there as the application’s main modules, so ignoring
it does not tidy anything - it fails the build with Could not find mainModule for 'os' architecture. .build/ is the opposite, the finished bundle
meteor build .build --directory writes. Both are now described where
somebody meets them: the headers of build.sh and build.bat,
Directory-Structure.md, Build-from-source.md, Meteor-bundle.md,
Build-and-Create-Pull-Request.md, and this file’s two AI instruction files.
The ignore files had drifted apart from each other in the meantime, so each
was brought to the same answer: _build reached .dockerignore,
.eslintignore and .prettierignore, which had never heard of it and were
linting and shipping a bundled copy of the app. .meteorignore gained the
trees that are not application source at all - docs/, meta/,
old-CHANGELOG/, openapi/, the packaging directories, releases/,
scripts/, tools/, .github/ and the editor and tooling directories -
after checking, rather than assuming, that nothing under client/, server/,
models/, imports/, config/ or packages/ imports from any of them.
Meteor takes one file watcher per directory it does not ignore, from a
per-user limit shared with every editor on the machine, and that limit is what
.tools/ was added for in the first place.
Ignoring .build does not silence Meteor's warning about it, and no longer says it does. Thanks to xet7.
The comment added beside /.build/ claimed the entry was this warning
answered:
WARNING: The output directory is under your source tree.
Your generated files may get interpreted as source code!
The very next build printed it again. The warning is a path comparison on the
output argument, made before any file is written - tools/cli/commands.js
asks whether pathRelative(appDir, outputPath) starts with .. and prints it
when it does not. No ignore file is consulted and none can reach it; silencing
it would mean building outside the tree, which build.sh, build.bat and the
release workflows would all have to agree on.
The entry is still worth having, for the second half of the same sentence:
without it the next meteor run walks a whole bundled copy of the app. That
is what the comment says now. tests/meteorignoreScanScope.test.cjs pins both
directions - _build must not be ignored, .build must be - and fails if the
false claim comes back.
and improves release automation:
Variant repositories - one authoritative source and two compatibility names.
Update Ondra and Gantt repositories as a required release job. Thanks to xet7.
The compatibility snaps published successfully while their GitHub repositories
stayed stale because repository synchronization was an optional step inside a
continue-on-error architecture matrix. A missing or insufficient repository
token could skip or fail that step without failing the release.
A reusable workflow now updates both repositories from the release tag as its own required job. Its shared preparation script copies only committed WeKan source and preserves each variant’s Snap, npm, GHCR, Quay and Docker Hub package identities. Fixture tests cover both variants, reject dirty targets and prove that ignored local files do not enter the synchronized repositories. Snap publication remains independent of repository synchronization.
Keep Dependabot updates in WeKan instead of its release mirrors. Thanks to xet7.
Synchronizing the complete source tree also copied WeKan’s Dependabot
configuration into wekan-ondra and wekan-gantt-gpl. Both mirrors then opened
duplicate dependency PRs against snapshots that later synchronization replaces.
The preparation step now removes both supported Dependabot configuration
filenames. Dependency changes remain reviewed and tested once in wekan/wekan
and reach each compatibility repository through the normal sync. Positive and
negative fixture coverage pins the exclusion for both variants.
Test suite - full runs inspect source rather than generated copies.
Keep source scans out of builds and exercise minicard links through the UI. Thanks to xet7.
The complete test run found two scanners walking generated .build-* bundles:
one mistook bundled history calls for source without imports, while the security
map had not yet associated nine published vulnerability names with their
existing regression suites. Build variants are now excluded and the suites name
the disclosures they cover.
The minicard markdown-link browser test also called the module-scoped
ReactiveCache identifier as if it were a browser global, failing in Chromium
and Firefox before testing the link. It now creates the markdown title through
the real card editor, closes the card and verifies that the minicard link opens
without restoring inline title editing.
Keep consistency checks aligned with release helpers and security names. Thanks to xet7.
The complete test rerun found four consistency failures rather than application failures. The new variant preparation helper now has its documented workflow-only menu exemption, and the variant design explicitly retains historical Docker tags without claiming that new variant images are published. Upcoming entries are grouped by their actual areas.
The security inventory previously used substring matching, so naming
CookieTokenBleed made it falsely conclude that the unrelated TokenBleed gap
had acquired coverage. Vulnerability names now require non-alphanumeric
boundaries. The four suites that failed in the complete run pass together.
and improves documentation:
Outgoing email documentation - current configuration and readable Markdown.
Remove visible Liquid tags from email troubleshooting. Thanks to xet7.
The email troubleshooting document wrapped its entire contents in Liquid
raw tags to protect one literal template placeholder. GitHub’s normal
Markdown view displayed those tags as document text. The example now uses a
fixed escaped regular-expression literal, which still replaces every exact
placeholder without presenting Liquid syntax to a Pages build. Tests pin the
rendering boundary and positive and negative replacement behavior.
Email troubleshooting starts with the Admin Panel provider choices. Thanks to xet7.
The email troubleshooting page still said email could only be configured with
MAIL_URL, and the Admin Panel page described its live provider fields as
commented out. Both now lead with Admin Panel / People / E-mail and the
Enable below email settings opt-in. They explain that administrators can
choose custom SMTP or the built-in Gmail, Outlook 365, Proton, SendGrid,
Mailgun, Postmark, Resend and AWS SES options, while leaving the switch disabled
continues to use MAIL_URL and MAIL_FROM. The general webserver settings page
points to the same two choices.
and improves the translations:
Bosnian - a language file that was almost entirely English.
2175 untranslated strings become one. Thanks to xet7.
Croatian and Serbian are complete and Bosnian is the same Štokavian standard, so Croatian is the base with the documented Bosnian forms applied - sedmica for tjedan, nivo for razina, server for poslužitelj, tok for tijek, hiljada for tisuća, historija for povijest, and tačka/tačno for točka/točno. 29 strings needed one; the rest are identical in both standards.
Two substitutions were REVERTED after reading the output, which is the part worth keeping: poveznica → link produced “iz ove linkove” and “iz bilo koje linkove na kartu”, because poveznice is both nominative plural and genitive singular and one rule cannot be both. Poveznica is good Bosnian, so it stays - a correct word left alone beats a more idiomatic one put in the wrong case. The same blindness left “Najviša nivo”, a feminine adjective on a masculine noun.
The last ten were translated directly. The one that remains is Server, spelled that way in Bosnian too - the tool counts a translation identical to its source as missing, which is its limit rather than a gap.
Thanks to above GitHub users for their contributions and translators for their translations.