Compare commits

...
30 Commits
Author SHA1 Message Date
github-actions[bot] 149944726e Bump versions for v10.78 2026-08-09 15:27:00 +00:00
Lauri Ojansivu bf0b757862 Prepare v10.78 release 2026-08-09 18:25:59 +03:00
Lauri Ojansivu 1c7de782f0 CHANGELOG: the snap and release-notes fixes from the v10.77 build logs.
Thanks to xet7 !
2026-08-09 18:21:43 +03:00
Lauri Ojansivu a820b056b2 Snap: libcurl4 does not exist on armhf, so name the package noble ships.
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 !
2026-08-09 18:20:42 +03:00
Lauri Ojansivu 6dce9dda79 Release notes and snap logs: report what really happened.
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 !
2026-08-09 18:15:22 +03:00
Lauri Ojansivu 47c81cd478 CHANGELOG: rewrap the Upcoming summary.
Thanks to xet7 !
2026-08-09 16:01:11 +03:00
Lauri Ojansivu bc426ba7c2 CHANGELOG: the Upcoming binaries table is FerretDB v1.48.0, not v10.77's v1.45.0.
Thanks to xet7 !
2026-08-09 15:58:58 +03:00
Lauri Ojansivu a0f445c723 CHANGELOG: the Docker provenance and bundled-binary freshness work.
Thanks to xet7 !
2026-08-09 15:51:44 +03:00
Lauri Ojansivu ce69db8c5f Pin that every bundled binary is fetched as the newest one.
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 !
2026-08-09 15:50:11 +03:00
Lauri Ojansivu 5b31695343 Docker images: full build provenance, at every site that pushes one.
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 !
2026-08-09 15:38:53 +03:00
Lauri Ojansivu 4a8fbd954e CHANGELOG: the Flatpak fix, beside the AppImage one it repeats.
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 !
2026-08-09 15:25:28 +03:00
Lauri Ojansivu 04fb6e3381 Flatpak: attach the bundles to the release, and only the bundles.
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 !
2026-08-09 15:13:39 +03:00
Lauri Ojansivu 5957fa0ad8 CHANGELOG: the two AppImage commits, under a new Upcoming section.
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 !
2026-08-09 15:09:54 +03:00
Lauri Ojansivu 97e57e531b AppImage: add the other two architectures a runtime exists for.
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 !
2026-08-09 15:08:23 +03:00
Lauri Ojansivu fa296d0c80 AppImage: attach the built AppImages to the release again.
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 !
2026-08-09 15:08:23 +03:00
github-actions[bot] 040c18bc23 Bump versions for v10.77 2026-08-09 11:16:31 +00:00
Lauri Ojansivu 7241570491 Prepare v10.77 release 2026-08-09 14:15:35 +03:00
Lauri Ojansivu 979ad3a5cd CHANGELOG: the two snap commits, under a new Upcoming section.
v10.76 was cut while they were being written, so they get their own Upcoming
above the release.

Thanks to xet7 !
2026-08-09 14:09:41 +03:00
Lauri Ojansivu 3c9291338c Snap CPU platforms: say which six, why not the other two, and all four channels.
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 !
2026-08-09 14:05:17 +03:00
Lauri Ojansivu a5d8a1fd97 Snap: release every snap, every architecture, to all four channels.
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 !
2026-08-09 14:05:17 +03:00
github-actions[bot] ab29f74f6a Bump versions for v10.76 2026-08-09 02:36:55 +00:00
Lauri Ojansivu 48d8dc9de8 CHANGELOG: repoint commit links after history moved.
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 !
2026-08-09 05:35:11 +03:00
Lauri Ojansivu 983aba8488 Prepare v10.76 release 2026-08-09 05:35:10 +03:00
Lauri Ojansivu 988e2f0794 CHANGELOG: the three round-trip fixes, under a new Upcoming section.
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 !
2026-08-09 05:35:10 +03:00
Lauri Ojansivu 90a41dbb22 Two queries per request instead of two per card.
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 !
2026-08-09 05:35:10 +03:00
github-actions[bot] 8da91adaa7 Bump versions for v10.75 2026-08-09 01:48:39 +00:00
Lauri Ojansivu 80830cd43e Prepare v10.75 release 2026-08-09 04:46:10 +03:00
Lauri Ojansivu f2edde0651 CHANGELOG: a new Upcoming section for the two commits after v10.74.
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 !
2026-08-09 04:44:59 +03:00
Lauri Ojansivu ada480c93a Linked-card ids must not be cached for the life of a subscription.
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 !
2026-08-09 04:43:30 +03:00
Lauri Ojansivu 114d6c0599 Build script parity: git pull and git push, on Windows too.
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 !
2026-08-09 04:43:30 +03:00
33 changed files with 2793 additions and 95 deletions

No files matched your search

+65 -6
View File
@@ -92,7 +92,12 @@ jobs:
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: a call that relies on an inferred remote breaks the day
# somebody removes the checkout, and the error it gives - "fatal: not
# a git repository" - names neither gh nor the workflow.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -107,6 +112,9 @@ jobs:
name: AppImage ${{ matrix.appimage_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
# A 32-bit job that cannot run on the runner it was given must not fail the
# release for the architectures that built fine.
continue-on-error: ${{ matrix.tolerate_failure || false }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -118,6 +126,28 @@ jobs:
- appimage_arch: aarch64
bundle_arch: arm64
runs_on: ubuntu-24.04-arm
# The other two architectures an AppImage runtime EXISTS for. There
# are four in total - x86_64, aarch64, i686, armhf - and that is the
# whole limit: the runtime is the ELF the kernel executes, so an
# architecture with no runtime cannot have an AppImage at all. That is
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
# be; they are served by the bundle zip and the .deb.
#
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
- appimage_arch: i686
bundle_arch: i386
runs_on: ubuntu-24.04
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
# installed. GitHub does not promise either, so this one is allowed to
# fail without taking the release with it - the same treatment TSC
# gives its emulated armhf job. If it turns out to work reliably,
# drop the flag; if it never does, drop the entry.
- appimage_arch: armhf
bundle_arch: armhf
runs_on: ubuntu-24.04-arm
tolerate_failure: true
# WHICH entries this run wants, decided here because it cannot be decided
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
# that context to runs-on, env, name, steps and a few others, but not to a
@@ -163,7 +193,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
# A zip that exists is not a bundle that runs; these are the two files
# the AppImage is built around.
@@ -281,6 +312,22 @@ jobs:
# 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."
exit 0
fi
fi
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
pid=$!
ok=false
@@ -324,7 +371,11 @@ jobs:
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ls assets/*.AppImage >/dev/null 2>&1; then
for want in x86_64 aarch64; do
# Every architecture the matrix builds. A missing one WARNS rather
# than fails: the 32-bit pair is allowed not to build, and an
# architecture whose base bundle was never published has nothing to
# wrap. What must not happen silently is one going missing unnoticed.
for want in x86_64 aarch64 i686 armhf; do
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
echo "::warning::no AppImage for $want - see that build job's log"
done
@@ -334,7 +385,7 @@ jobs:
# build jobs skip (not fail) when the base bundle is not published yet.
published="$(gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
base_present=false
for arch in amd64 arm64; do
for arch in amd64 arm64 i386 armhf; do
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
done
if [ "$base_present" = false ]; then
@@ -354,5 +405,13 @@ jobs:
echo "Nothing to upload (no base bundle was published to wrap)."
exit 0
fi
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --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
# then nothing was attached to the release.
gh release upload '${{ needs.release.outputs.tag }}' assets/* \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
+31 -4
View File
@@ -24,6 +24,15 @@ name: Flatpak
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
# Each builds on a runner of its own CPU rather than under emulation.
#
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
# a RUNTIME, not against the host's libraries, so an architecture exists here
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
# the difference from AppImage.yml, which was able to grow from two
# architectures to four: an AppImage carries its own runtime binary, and
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
# loong64 and 32-bit bundles are served by the zip and the .deb.
#
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
# and a place to keep its data, and nothing else - no X11, no audio, no access
# to your files. --share=network and --persist=.wekan are the whole permission
@@ -96,7 +105,11 @@ jobs:
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: one that relies on an inferred remote breaks the day
# somebody removes the checkout.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -154,7 +167,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
@@ -318,8 +332,21 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --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". The same
# line was wrong in AppImage.yml for the same reason.
#
# And the bundles BY NAME, not assets/*: the artifacts also carry the
# ostree repository the flatpak was exported through - config,
# objects/, refs/, summaries/, summary, summary.idx - which is build
# scaffolding, not something to attach to a release. TSC hit this
# exact thing ("upload only the bundle files, not the ostree repo").
gh release upload '${{ needs.release.outputs.tag }}' \
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
# ---- the repository -------------------------------------------------
- uses: actions/download-artifact@v8
+4
View File
@@ -87,3 +87,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Full build provenance (SLSA), not the minimal default that BuildKit
# attaches on its own. See release-all.yml for what it is and why the
# manifest list gains an `unknown/unknown` entry per platform.
provenance: mode=max
+73 -12
View File
@@ -622,13 +622,23 @@ jobs:
sha256sum "wekan-${VERSION}-amd64.zip" > "wekan-${VERSION}-amd64.zip.sha256sum"
cat "wekan-${VERSION}-amd64.zip.sha256sum"
# Where this bundle's binaries came from, for the release notes.
mkdir -p provenance
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
#
# $GITHUB_WORKSPACE, not a relative path: this step ran `cd .build`
# above, so `mkdir -p provenance` made .build/provenance while the
# upload-artifact below looks at provenance/ from the workspace root.
# It found nothing - "No files were found with the provided path:
# provenance/" - and amd64, the platform most people download, was the
# one bundle missing from the v10.77 release-notes table. Every other
# build job records from the workspace root, which is why only this one
# was affected.
mkdir -p "$GITHUB_WORKSPACE/provenance"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' 'latest' \
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -832,8 +842,9 @@ jobs:
'arm64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/arm64.tsv bash releases/record-provenance.sh \
'arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -1219,8 +1230,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win64.tsv bash src/releases/record-provenance.sh \
'win64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1561,8 +1573,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win32.tsv bash src/releases/record-provenance.sh \
'win32' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win32' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1805,8 +1818,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64' \
"${FERRET_SHA:-}" || true
fi
@@ -2057,8 +2071,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
'mac-x64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-x64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64' \
"${FERRET_SHA:-}" || true
fi
@@ -2421,7 +2436,7 @@ jobs:
else
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${{ matrix.ferretdb_arch }} on this release, so it is unverified."
fi
FERRET_TAG="$(curl -fsSL https://api.github.com/repos/wekan/FerretDB/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag_name",""))' || true)"
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
bash releases/record-provenance.sh '${{ matrix.arch }}' 'FerretDB' \
'wekan/FerretDB' "${FERRET_TAG:-latest}" "$FERRET_URL" "$FERRET_SHA"
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
@@ -2966,7 +2981,21 @@ jobs:
# manifest yet, so it ships as a .zip bundle only. Every candidate arch
# is in debian:trixie's manifest AND has a fork Node the Dockerfile can
# install.
# --provenance=mode=max: full build provenance (SLSA), not the
# minimal default - what built this image, from which commit, with
# which base and build arguments. It rides in the manifest list as an
# `unknown/unknown` entry per platform, the OCI marker for "not a
# runnable image" so `docker pull` never selects it, which is why Quay
# lists one beside each real platform. mode=max makes those entries
# BIGGER, not fewer; `--provenance=false` is what removes them, at the
# cost of the evidence.
#
# The comment is here and not among the arguments: a `#` after a line
# continuation comments out the rest of the joined line, leaving
# `docker buildx build` with no platforms and no tags. This block is a
# shell script like any other, and it was written the wrong way first.
docker buildx build \
--provenance=mode=max \
--platform "${PLATFORMS}" \
--build-arg VERSION=${VERSION} \
-t wekanteam/wekan:v${VERSION} \
@@ -3521,6 +3550,19 @@ jobs:
fi
done
echo "::error::Remote build ${{ matrix.arch }} failed after $attempts attempts (no wekan_${VERSION}_${{ matrix.arch }}.snap produced). The Launchpad build log printed above says why; the builds are also at https://launchpad.net/~xet7/+snaps" >&2
# A DETERMINISTIC failure, named before the guesses below. Three
# identical attempts on ONE architecture while the others build on
# attempt 1 is not the build farm having a bad day - and the guidance
# below ("often an OOM, or a transient reset; re-run") sent v10.77's
# armhf failure down exactly that path. snapcraft ends a missing
# stage-package as a "Stopped" build with no artifact, which looks
# identical to a cancelled one from the outside; the difference is one
# line in the log, so say it here rather than leave it to be found.
if grep -qs 'Stage package not found' snapcraft-wekan-*.txt; then
missingpkg="$(grep -hs -o "Stage package not found in part '[^']*': [^ ]*" snapcraft-wekan-*.txt | sort -u | tr '\n' ' ')"
echo "::error::This is NOT a transient failure and re-running will not fix it: ${missingpkg}. A stage-package that resolves on the 64-bit architectures can be missing on ${{ matrix.arch }} - Ubuntu 24.04's 64-bit time_t transition renamed libraries to a t64 suffix, and the renamed package carries 'Provides: <old name>' on amd64/arm64 but NOT on armhf, where the ABI really changed. Name the real package in snapcraft.yaml (libssl3 -> libssl3t64, libcurl4 -> libcurl4t64, libgoogle-perftools4 -> libgoogle-perftools4t64). Check a name against the archive: curl -fsSL http://ports.ubuntu.com/ubuntu-ports/dists/noble/main/binary-armhf/Packages.gz | gunzip -c | grep '^Package: <name>$'" >&2
exit 1
fi
# A "Stopped" build with no artifact is Launchpad ENDING the build
# itself, not a compile error in it. Two causes, in the order to check:
# 1. The auto-generated Launchpad PROJECT (xet7-craft-remote-build)
@@ -3563,11 +3605,29 @@ jobs:
run: |
set -uo pipefail
mkdir -p snap-logs
cp -f snapcraft-wekan-*.txt snap-logs/ 2>/dev/null || true
cp -f "$HOME"/.local/state/snapcraft/log/*.log snap-logs/ 2>/dev/null || true
# RENAMED, not just copied. snapcraft names a remote-build log after the
# recipe and an ISO timestamp:
# snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
# and upload-artifact REFUSES a colon - "Invalid characters include:
# Double quote, Colon, Less than, ..." - because NTFS cannot hold one.
# That failed the whole job for s390x, ppc64el and riscv64 in v10.77
# AFTER all three snaps had built and downloaded on attempt 1, so three
# good builds were reported as FAILED by the step that saves their logs.
# The timestamp is worth keeping, so the colons become dashes rather
# than the name being thrown away.
for f in snapcraft-wekan-*.txt "$HOME"/.local/state/snapcraft/log/*.log; do
[ -f "$f" ] || continue
base="$(basename "$f")"
cp -f "$f" "snap-logs/${base//:/-}" || true
done
ls -la snap-logs || true
- uses: actions/upload-artifact@v7
# A DIAGNOSTIC upload can never fail the build it is diagnosing. The snap
# is the deliverable; these logs are here so a failure can be read
# afterwards, and the renaming above is not the last thing that could
# ever make one of them unuploadable.
continue-on-error: true
if: always()
with:
name: snap-launchpad-logs-${{ matrix.arch }}
@@ -4002,6 +4062,7 @@ jobs:
set -euo pipefail
cd exapp
docker buildx build --push \
--provenance=mode=max \
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
-t ghcr.io/wekan/wekan-exapp:latest \
--build-arg WEKAN_VERSION=${VERSION} .
+629
View File
@@ -264,6 +264,635 @@ browser build to verify).
</details>
# v10.78 2026-08-09 WeKan ® release
**In short:** the **AppImage** workflow. It built both AppImages, started them,
waited for WeKan to answer on port 8080, uploaded them as artifacts - and then
attached nothing to the release, because the job that does the attaching never
checks the repository out and its two `gh` calls were the only ones in the file
without `--repo`. Fixed, and the matrix grows from two architectures to
**four**: `i686` and `armhf` are the other two an AppImage runtime exists for.
The **Flatpak** workflow had the same one-line bug and a second beside it - it
was attaching the ostree repository along with the bundles - and it stays at two
architectures, because a flatpak needs a published runtime and only x86_64 and
aarch64 have one. Beside that, the **Docker** images now carry full SLSA
**provenance** at every one of the four sites that pushes one, and a test pins
that every **bundled binary** - FerretDB, the MongoDB Database Tools, Node.js -
is still fetched as the NEWEST one everywhere it is fetched, which is what makes
those projects' security fixes arrive without a commit here - and the table
below is that working: **FerretDB v1.48.0** replaces v10.77's v1.45.0 on every
platform, with no change in this repository, because `latest` now resolves to
it. That is the release carrying `go1.25.11` and `x/sys v0.46.0`, which answers
the Go advisories a Quay scan reported against the v10.77 image. Node.js stays
v24.19.0, the newest 24.x. The rest of this release is the same theme - what a
build REPORTS versus what it did. The **armhf snap** failed three times on a
package name that has not existed since Ubuntu 24.04 renamed it, while the
message blamed a transient build-farm reset; three snaps that BUILT were
reported FAILED by the step that saves their logs; and the provenance table
printed every row twice, left **amd64** out entirely, and gave six platforms the
version "latest".
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-amd64) | v1.48.0 | `2737687fd29a8a761cd960e45f300b68cf7b4a87d50c4cc5280bcbd42b6aa163` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-arm64) | v1.48.0 | `5ae705dd49515a4ecd4e295c3b9aa4f3b454fad78613ec60fb99316bd7c34e3f` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-loong64) | v1.48.0 | `06ec86263455a7b598d22a87df0e044ea73ab5a3b72e96ad12ebed03c1374ac2` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-arm64) | v1.48.0 | `9b15f4c10e473cd0a2c4feb4cb43e18042bd60c7035ec66cab3cfbe13edaabab` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-mac-amd64) | v1.48.0 | `4e188246dfa33bccef4cdd86701bc498b037cb3e91f579ff0dccb93aa0ef03ad` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-ppc64le) | v1.48.0 | `0400cd6dfc3d10d987a0fe80d75baa86c03c19170770fa2e602c92d558c3cfa6` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-riscv64) | v1.48.0 | `d37c35af988670b9ed182b8c5966c06a06362f6c6ace6aebd93ccdfa32c9a26b` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-s390x) | v1.48.0 | `6c7d61fbb8c79b2e8733be8f71910f710e8c5cd25208c451bdc513c8313b0340` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.48.0/ferretdb-win64.exe) | v1.48.0 | `ea57e1bcd153b51d2065ab01515b21ec05d8f615444c15603ab8158b8a661dd2` |
This release adds the following new features:
**AppImage** - which CPUs get one.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/97e57e531">Build the other two architectures an AppImage runtime exists for</a>. Thanks to xet7.</summary>
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 those four. `ppc64le`, `s390x`, `riscv64` and `loong64` have
none, so they cannot have an AppImage 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 are not treated as though they were. **i686**
builds on the x86_64 runner, which runs 32-bit x86 natively: no emulation, no
multiarch, `appimagetool-i686` runs as-is. **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. It
cannot decide whether the other three reach the release.
The smoke test needed the distinction too. It starts the AppImage and waits for
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, it warns, says the AppImage 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.
</details>
**Docker images** - what an image records about how it was built.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5b3169534">Attach full build provenance at every site that pushes an image</a>. Thanks to xet7.</summary>
BuildKit attaches MINIMAL provenance on its own, which is where the
`unknown on unknown` rows beside each real platform on quay.io come from - that
is the OCI marker for "not a runnable image", so `docker pull` never selects
one. Minimal is a build id and a timestamp, which answers nothing anybody asks
of a supply chain. `mode=max` records what does: which commit, which base
image, which build arguments, which frontend.
The hazard is not getting it wrong once, it is that WeKan pushes images from
FOUR places - `docker-publish.yml`, two sites in `release-all.yml`, and
`releases/docker-build.sh` - and a fifth added later would silently fall back to
the default. So the test finds the call sites by searching the repository
instead of from a list, and fails when a pushing one lacks the flag.
SBOM stays off, deliberately: it enumerates every OS package and npm
dependency, so the attestation grows from tens of kilobytes to megabytes per
platform. Turning it on is a decision rather than a default to drift into.
The `--load` build in `docker-build.sh` must NOT ask for it - the docker
exporter cannot carry an attestation at all - and that is a test of its own, so
the flag is not added there by symmetry one day.
One more thing the test pins, because it was written the wrong way twice while
this was being done: a `#` comment sitting among a continued command's
arguments. `docker buildx build \` followed by a comment line comments out the
REST OF THE JOINED LINE, so the command becomes a bare `docker buildx build`
with every platform, tag and flag swallowed. `bash -n` accepts it - it is valid
syntax, just a different command - and a YAML `run:` block is a shell script,
which is where it happened the first time.
</details>
and fixes the following bugs:
**The snap builds** - what the Launchpad jobs build, and what they report.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a820b056b">libcurl4 does not exist on armhf, so name the package noble ships</a>. Thanks to xet7.</summary>
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, `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, and
the comment above this list describes it exactly; `libcurl4` was the same
transition and was left behind. All fourteen stage-packages were checked against
the noble armhf main+universe indices this time, and it 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 offered "an
OOM in the Meteor npm install, or a transient build-farm reset - re-run" for a
failure that will never succeed on a re-run. The step now looks for that line
first, names the missing package, says it is not transient, explains the
`Provides` asymmetry, and gives the one-line archive query that checks a name.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6dce9dda7">Three good snaps were reported as FAILED by the step that saves their logs</a>. Thanks to xet7.</summary>
s390x, ppc64el and riscv64 each printed *"Remote build &lt;arch&gt; 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,
and the renaming is not the last thing that could ever make a log unuploadable.
</details>
**The release notes** - what the provenance table says each bundle carries.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6dce9dda7">Every row appeared twice, amd64 appeared not at all, and six platforms said "latest"</a>. Thanks to xet7.</summary>
Three things wrong at once in the v10.77 table, and every one of them silent -
nothing errored, the table was simply not what it claimed to be.
**Every row appeared twice.** `provenance-table.sh` globbed
`provenance/**/*.tsv provenance/*.tsv`, and with `globstar` a `**/` 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 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** - the platform most people download. Its step runs
`cd .build` before `mkdir -p provenance`, so the rows went to
`.build/provenance` while the upload looked at `provenance/` from the workspace
root and found nothing. Every other build job records from the workspace root,
which is why only this one was affected.
**Six platforms said Version `latest`.** amd64, arm64, win64, win32, mac-arm64
and mac-x64 passed the literal string; only the extra-architecture job asked
what `latest` actually was, with its own inline `curl`. "Which FerretDB did
v10.77 ship" is the one question that column exists to answer. All seven sites
now call `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 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 - a
release note must never fail a build that produced a good bundle.
The table's prose also linked `wekan/node`; the binaries come from
[wekan/node-patches](https://github.com/wekan/node-patches), which is what the
rows themselves already linked.
</details>
**The release upload** - what reaches the release page.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/fa296d0c8">Attach the built AppImages to the release again</a>. Thanks to xet7.</summary>
Both AppImages built. Both passed the smoke test that starts them and waits for
WeKan to answer. Both uploaded as artifacts, and both were downloaded by the
attaching job with matching SHA256 digests. Then:
```
gh release upload 'v10.77' assets/* --clobber
failed to run git: fatal: not a git repository
```
That job does not check the repository out - it has no reason to, it only
downloads artifacts - so `gh` had no git remote to infer the repository from.
Every other `gh` call in the workflow already passed `--repo`; those two did
not.
Every call passes it now, including the ones in jobs that DO check out: a call
relying 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 that failed run, because the log looks far
worse than the bug: `::error::` lines appear in the build jobs for the
bundle-file check and the port check, and in the attaching job for "no
architecture produced an AppImage - every build job failed". None of them fired.
They carry the escape codes of the `##[group]` header - they are the SCRIPT
being echoed, not output - and a few lines below them `ls -lh assets` shows both
AppImages sitting there at 225M and 227M. One line in the whole run was a real
error.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/04fb6e338">Flatpak: attach the bundles to the release, and only the bundles</a>. Thanks to xet7.</summary>
The same one-line bug as the AppImage workflow above, from the same cause: the
job that attaches the bundles does not check the repository out - it only
downloads artifacts - so `gh` had no git remote to infer the repository from and
`gh release upload` failed with *"fatal: not a git repository"*. Both flatpaks
had built and uploaded as artifacts; nothing reached the release. Every `gh`
call in that workflow passes `--repo` now too.
And a second one beside it: `assets/*` was 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 would have put a few hundred directories on the
release beside the two files anyone wants. The upload names the bundles and
their checksums instead.
No architectures could be added here, and the workflow header now says why
rather than leaving it to be rediscovered. A flatpak runs against a RUNTIME, not
the host's libraries, so an architecture exists 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 the AppImage
work above, which could grow from two architectures to four - an AppImage
carries its own runtime binary, and those exist for i686 and armhf as well.
</details>
and has the following developer-facing change:
**Bundled binaries** - keeping "newest" true everywhere it is claimed.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ce69db8c5">Pin that every bundled binary is fetched as the newest one</a>. Thanks to xet7.</summary>
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 that project's 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 URLs are found by searching the repository rather than from a list, so a
build site added later is checked too, and three shapes are accepted:
`latest/download/<asset>`, the bare `latest` API endpoint that `release-all.yml`
asks which version `latest` resolved to so the provenance table can record it,
and `${VAR}` whose default is separately asserted to be `latest` - that is
`FERRETDB_RELEASE` in the compose files, which an operator may pin for their own
reasons but which must not freeze everybody who does not.
Node.js is a different mechanism with the same effect, so it is pinned too:
`NODE_VERSION` is the bare major `24`, and `releases/resolve-node-source.sh`
answers with that CPU's newest 24.x from whichever of nodejs.org,
unofficial-builds or [wekan/node-patches](https://github.com/wekan/node-patches)
has one.
What makes `latest` safe rather than merely convenient is the provenance table
above: rebuilding an old release would embed a different FerretDB than it
shipped with, and the only reason that is a trade rather than a hole is that
every release RECORDS the versions and SHA256s it actually shipped. So that is
asserted here as well - `releases/record-provenance.sh` exists, and the
CHANGELOG still carries the table it produces.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.77 2026-08-09 WeKan ® release
**In short:** the **snap** side, which had three problems that looked like one.
The helper that put a build on channels released ONE snap, ONE revision, to
THREE channels - and a revision number is per architecture, so it could only
ever
be right for one of them. The page documenting the CPU platforms listed five
architectures and omitted **armhf**, which has been built all along. And the two
architectures that are release bundles but NOT snaps - **i386** and **armv7** -
were nowhere, so "missing" and "cannot be there" looked identical. The binaries
below are v10.76's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release adds the following new feature:
**Snap publishing** - getting every snap onto every channel without typing a
revision number.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a5d8a1fd9">Release every snap, every architecture, to all four channels</a>. Thanks to xet7.</summary>
The helper this replaces was `snapcraft release wekan $1 edge,beta,candidate`,
which is 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 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 pair with no revision is reported and skipped rather than failing the
run: the three snaps genuinely have different architecture sets today.
The mapping is the hazard, and it lives in one place now
(`models/lib/snapArchitectures.js`) with both directions tested. `ppc64le` and
`ppc64el` ARE the same hardware - the bundles use the kernel's name, the store
uses Debian's - and it is the only rename. Nothing warns when the wrong one is
used: an unrecognised architecture is simply one the store has never heard of,
so it looks like it worked.
Pass the version to pin it. Without one 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; `--dry-run` prints the plan first.
</details>
and improves the following documentation:
**Snap CPU platforms** - which six, why not the other two, and how the names
differ.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3c9291338">Say which six architectures are snaps, and why i386 and armv7 are not</a>. Thanks to xet7.</summary>
`docs/Platforms/FOSS/Container/Snap/CPU-platforms.md` listed five architectures
and omitted **armhf**, which `snapcraft.yaml` has built all along. It said the
release publishes candidate, beta and edge and that stable "is published
manually
later" - no longer true, and the reason a build reached three channels of four.
The matrix is now the six `build-for:` entries with the bundle name beside each,
and a new section explains the three ways the two naming systems differ.
**armhf and armv7 are not a rename**, and getting it wrong ships a snap that
crashes. [node-patches](https://github.com/wekan/node-patches) builds `armhf` to
the Debian baseline - hard-float, VFPv3-D16, assuming no NEON - so it runs on
any
ARMv7-A, and `armv7` with NEON for boards that have it. The Snap 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. So
armv7 ships as a bundle only, and a test cross-checks that explanation against
node-patches' own workflow so it cannot drift from the binaries.
**i386 cannot have a new snap at all**, and it 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 produces a base Canonical does not publish. The last base with one was
`core18`, end-of-life. The store still shows an i386 column for `wekan-ondra`
because it keeps whatever was ever uploaded; that revision is `0.X-ci` and
nothing can replace it.
The page also records what each snap has in the store today and what is still to
upload, including the two fossils 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.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.76 2026-08-09 WeKan ® release
**In short:** three round-trip counts that were linear in the input, and are not
any more. The **bulk label** endpoint asked the database twice per card - a
thousand sequential round-trips at its 500-card cap - **global search** resolved
each named user with its own lookup before the search could start, and
**FerretDB** skipped every top-level `$or` when building a WHERE clause, so the
board-list query narrowed nothing in SQL and filtered every row in Go. None of
them was a wrong answer; each was the right question asked one document at a
time. The binaries below are v10.75's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release makes the following faster:
**The REST API** - how many times one request talks to the database.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Bulk label changes read every card in one query, and write them together</a>. Thanks to xet7.</summary>
`PUT .../cards/labels` awaited a `getCard` per id and then an update per id. At
the `BULK_CARDS_MAX` of 500 that is **one thousand sequential round-trips** for
a single request, each starting only once the last one finished.
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 had to survive. The loop still iterates the caller's `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 and each card gets its own activity - so
batching the reads would save one query inside a loop that does far more than
query.
</details>
**Global search** - what happens before the search itself starts.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Every username a query names is resolved in one lookup</a>. Thanks to xet7.</summary>
Each `user:`, `member:`, `assignee:` and `creator:` predicate resolved its name
with its own awaited `findOne`, so `member:ann member:bob member:carol` was
three serial round-trips before the search could begin.
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 rather than two.
An unknown name is still reported against the operator it was typed under: "ann
is not a user" is not useful without saying where ann was typed.
</details>
**The database** - what SQLite is asked, and what is filtered afterwards.
<details>
<summary><a href="https://github.com/wekan/FerretDB/commit/dab729eb">FerretDB pushes a top-level $or down to SQL when every branch can be</a>. Thanks to xet7.</summary>
FerretDB skipped every top-level `$`-key when building its WHERE clause, so a
selector whose only SELECTIVE terms sit inside an `$or` produced a clause that
narrowed nothing: SQLite returned the rows, and every one was decoded and
filtered in Go to return a handful.
That is the shape of WeKan's "which boards may this user see" query, and the
worst possible one for it - `archived = false` and `type = 'board'` push down
and match nearly everything, while the membership clauses that actually select
stayed in Go. On an instance with ten thousand boards where a user belongs to
five, that decoded ten thousand documents to return five, on every All Boards
load.
It is all or nothing, and that is the whole subtlety. Every other pushdown
NARROWS: a condition that cannot be expressed is dropped, the WHERE returns a
superset, and the Go filter removes the rest. An OR that drops a branch REMOVES
rows that match it, and the Go filter never sees them. So one unpushable branch
refuses the whole `$or`, as does a nested-operator branch and an empty one.
See [the FerretDB CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md)
for the database side.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.75 2026-08-09 WeKan ® release
**In short:** two things the full test run turned up, one in WeKan and one in
the scripts that run it. A **linked-card cache** that could not see a card
added after the first time a subscription asked, and the **build script parity**
guard that was the one red suite - right twice over, because `build.bat` really
was missing the two entries and the guard really could not tell a shell function
from a script. The binaries below are v10.74's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release fixes the following bug:
**The board publication** - what a subscriber is sent, and when.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ada480c93">Linked-card ids are no longer cached for the life of a subscription</a>. Thanks to xet7.</summary>
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, so the
next reader does not put it back.
</details>
and has the following developer-facing change:
**The build scripts** - what each menu offers, and what the guard between them
compares.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/114d6c059">git pull and git push work on Windows too, and the parity guard knows what a function entry is</a>. Thanks to xet7.</summary>
The one red suite in the full test run, and it was right twice over.
The commit that replaced `Update git` with a `git pull` and a `git push` that
finish the job added both 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 the suite
fail the moment `Update git` was replaced. A function entry is exempt from the
script comparison now and checked separately: `build.bat` must implement a label
of the same name, so one dropping out of a menu still fails.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.74 2026-08-09 WeKan ® release
**In short:** four security reports from **Alpastx**, all of them the same
+1 -1
View File
@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.74
ARG VERSION=10.78
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.74.0"
appVersion: "v10.78.0"
files:
userUploads:
- README.md
+8
View File
@@ -303,6 +303,7 @@ if "%choice%"=="3" call :rel_run "releases/snap-push-to-store.sh" "Path to the .
if "%choice%"=="4" call :rel_run "releases/release-snap.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="5" call :rel_run "releases/snap-store-revisions.sh" ""
if "%choice%"=="6" call :rel_run "releases/snap-store-release-revision-to-channels.sh" "Snap Store revision number"
if "%choice%"=="7" call :rel_run "releases/snap-release-all-channels.sh" "Version, or empty for the newest"
if "%choice%"=="7" call :rel_run "releases/snap-edge.sh" ""
if "%choice%"=="8" call :rel_run "releases/snap-stable.sh" ""
if "%choice%"=="9" call :rel_run "releases/snapcraft-help.sh" ""
@@ -560,6 +561,7 @@ echo snap-push-to-store Push one .snap to the Snap Store ^<P
echo release-snap Release the snap for one version ^<WeKan version, e.g. 10.50^>
echo snap-store-revisions List the newest Snap Store revisions
echo snap-store-release-revision-to-channels Release one store revision to edge, beta and candidate ^<Snap Store revision number^>
echo snap-release-all-channels Release every snap, every architecture, to all four channels ^<Version, or empty for the newest^>
echo snap-edge Switch the installed snap to the edge channel
echo snap-stable Switch the installed snap to the stable channel
echo snapcraft-help snapcraft help topics
@@ -642,6 +644,7 @@ if /I "%K%"=="snap-push-to-store" (set "CMD=bash releases/snap-push-to-store.sh"
if /I "%K%"=="release-snap" (set "CMD=bash releases/release-snap.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-revisions" (set "CMD=bash releases/snap-store-revisions.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-release-revision-to-channels" (set "CMD=bash releases/snap-store-release-revision-to-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-release-all-channels" (set "CMD=bash releases/snap-release-all-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-edge" (set "CMD=bash releases/snap-edge.sh" ^& goto cli_go)
if /I "%K%"=="snap-stable" (set "CMD=bash releases/snap-stable.sh" ^& goto cli_go)
if /I "%K%"=="snapcraft-help" (set "CMD=bash releases/snapcraft-help.sh" ^& goto cli_go)
@@ -676,6 +679,11 @@ if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
REM releases/, so this answers to the same two names with its own labels - the
REM ones the interactive menu already uses.
if /I "%K%"=="git-pull" (goto gitpull)
if /I "%K%"=="git-push" (goto gitpush)
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
+1
View File
@@ -1873,6 +1873,7 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
@@ -9,9 +9,13 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
- **New installs use FerretDB v1 (SQLite) by default on every CPU architecture.**
FerretDB v1 is the wekan/FerretDB fork with an embedded pure-Go SQLite backend
that speaks the MongoDB wire protocol, so WeKan talks to it exactly like MongoDB.
- **Six architectures are built as snaps**: amd64, arm64, armhf, ppc64el, riscv64
and s390x — exactly `snapcraft.yaml`'s `build-for:` list. i386 and armv7 are
release bundles but **not** snaps, for reasons that are not oversights; see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 *server* binaries (mongod) are included for amd64 and arm64 only.**
The other architectures (s390x, riscv64, ppc64le) have no MongoDB server — they
run FerretDB v1 only.
The other architectures (armhf, ppc64el, riscv64, s390x) have no MongoDB server
— they run FerretDB v1 only.
- **Every Snap platform includes the FerretDB v1 server and the MongoDB Database
Tools** (`mongodump`, `mongorestore`, `mongofiles`, `mongoexport`, …), on every
architecture.
@@ -30,13 +34,20 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
## Per-architecture matrix
| CPU arch | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|----------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64le | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| Snap arch | Bundle | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|-----------|--------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | `amd64` | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | `arm64` | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| armhf | `armhf` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64el | `ppc64le` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | `riscv64` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | `s390x` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
These six are exactly the `build-for:` entries in `snapcraft.yaml`. The **Bundle**
column is the release-asset name at
[github.com/wekan/wekan/releases](https://github.com/wekan/wekan/releases), which
is not always the Snap Store's name for the same hardware — see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 server (mongod)**: amd64/arm64 only (MongoDB ships no server for the others).
- **MongoDB Database Tools**: every arch — WeKan builds them for all platforms from
@@ -47,6 +58,60 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
All architectures default new installs to FerretDB v1 with `DDP_TRANSPORT=sockjs`
and polling reactivity.
## CPU architecture names and what they mean
An architecture can go by one name in the release bundles and another in the Snap
Store, and two names that look like variants of each other can be different
builds. Nothing warns about either: an unrecognised `--arch` is simply an
architecture the store has never heard of, so a wrong name looks like it worked.
The mapping is kept in one place, `models/lib/snapArchitectures.js`, and is unit
tested (`tests/snapArchitectures.test.cjs`).
### ppc64le and ppc64el are the same hardware
64-bit little-endian POWER. The bundles name it the way Node.js and the kernel do
(`ppc64le`); the Snap Store names it the way Debian does (`ppc64el`). It is the
**only** architecture whose two names differ, and a test asserts that stays true.
### armhf and armv7 are the same CPU family, built to different baselines
This one is not a naming difference, and getting it wrong ships a snap that
crashes. [wekan/node-patches](https://github.com/wekan/node-patches) builds two
32-bit ARM Node.js binaries:
| Bundle | Built for | Runs on |
|--------|-----------|---------|
| `armhf` | hard-float, **VFPv3-D16**, no NEON assumed — the Debian armhf baseline | **any** ARMv7-A board |
| `armv7` | hard-float, **NEON**, ARMv7-A tuned | only boards that have NEON (an ODroid-U3, say) |
The Snap Store has **one** 32-bit ARM architecture, `armhf`, and it serves every
such device. It must therefore carry the **baseline** build: the NEON one would
be an illegal instruction on any armhf board without NEON. So the armhf snap uses
the `armhf` bundle, and **`armv7` has no snap** — it ships as a bundle only. A
separate snap name or track would be the only way to offer the NEON build, which
is not worth a second listing for one board family.
### i386 has no snap, and cannot have a new one
core24 is Ubuntu 24.04, which has **no i386 port**, so there is no i386 core24
base snap. `build-on: i386` is not merely unsupported — it is a **parse error**,
and because snapcraft parses the whole file first it failed *every* architecture's
build, not only i386's. This is not something a patch set can fix the way
[node-patches](https://github.com/wekan/node-patches) fixes a missing Node.js
build: node-patches patches source so a binary *can* be built, whereas here the
**base snap itself does not exist** for the architecture. The last base with a
real i386 port was `core18`, which is end-of-life.
The Snap Store still shows an i386 column for **wekan-ondra** (revision 70,
version `0.X-ci`) because the store keeps whatever was ever uploaded. It is years
stale, nothing new can be built for it, and re-releasing it would only re-publish
`0.X-ci`. **i386 users are served by the .deb and the AppImage.**
### loong64, win64, win32, mac-x64, mac-arm64
Bundle platforms with no Snap Store architecture at all. They are not snaps and
are not expected to be.
## New installs
On a fresh install, every architecture starts on **FerretDB v1 (SQLite)** with
@@ -151,18 +216,82 @@ bundle the snap's `wekan` part downloads, and staged to `$SNAP/ferretdb`
`<tool>-<arch>` from [wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches/releases),
and `mongosh-<arch>` from [wekan/mongosh](https://github.com/wekan/mongosh/releases).
## Snap channels
## Snaps and channels
The default `snapcraft.yaml` is built on `base: core24`, which allows the
**candidate** channel (core26 does not). The automated release workflow publishes
it to the **candidate**, **beta**, and **edge** channels; the **stable** channel is
published manually later, once it is proven.
`snapcraft-core26.yaml` is the same WeKan on the newer `base: core26`
(`build-base: devel`, `grade: devel`), kept for testing the next base; it can only
go to Beta/Edge until core26 is a released stable base.
There are **three snaps** of the same application, and each should reach **all
four channels on every architecture it is built for**:
| Snap | What it is |
|------|------------|
| [`wekan`](https://snapcraft.io/wekan) | the default |
| [`wekan-ondra`](https://snapcraft.io/wekan-ondra) | same WeKan, separate listing |
| [`wekan-gantt-gpl`](https://snapcraft.io/wekan-gantt-gpl) | same WeKan, separate listing |
**All four channels — Stable, Candidate, Beta and Edge.** The default
`snapcraft.yaml` is built on `base: core24`, a *released* base, so the snap carries
`grade: stable` and may be published to **stable** as well as the other three.
That is what makes the full set possible: a `grade: devel` snap is refused by
stable and candidate outright.
`snapcraft-core26.yaml` is the same WeKan on `base: core26`, kept for testing the
next base and **not** built by the release: core26 still requires
`build-base: devel` and therefore `grade: devel`, so it can only reach beta and
edge. That is the whole reason the release builds core24.
### Publishing to every channel
A revision is **per architecture** — the store's own listing shows `wekan` at
revision 3601 on amd64 and 3600 on arm64 for the same version 10.76 — so one
revision number can never be released to every architecture. Use:
```bash
releases/snap-release-all-channels.sh --dry-run # see the plan first
releases/snap-release-all-channels.sh 10.76 # pin the version
releases/snap-release-all-channels.sh # or the newest of each
```
It resolves the revision per (snap, architecture) from the store and releases it
to `stable,candidate,beta,edge` in **one** call, so a revision reaches all four
or none. A (snap, architecture) with no revision is reported and skipped — the
three snaps genuinely have different architecture sets today. It reads the
architecture table from `models/lib/snapArchitectures.js`, so there is no second
copy of it in shell.
**Pinning the version matters.** Without one, 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. Pass the version, or `--dry-run` first.
### What each snap currently has, and what is still to add
As of WeKan 10.76 the store holds:
| Snap | amd64 | arm64 | armhf | ppc64el | riscv64 | s390x |
|------|:-----:|:-----:|:-----:|:-------:|:-------:|:-----:|
| `wekan` | ✅ | ✅ | — | ✅ | — | ✅ |
| `wekan-ondra` | ✅ | ✅ | ⚠️ | — | — | — |
| `wekan-gantt-gpl` | ✅ | ✅ | — | — | — | — |
- **—** the architecture is built by `snapcraft.yaml` but that snap has no
revision for it yet. Uploading one is all that is needed; the release script
then puts it on all four channels.
- **⚠️** `wekan-ondra`'s armhf is stuck at version **0.22**, and its i386 column at
**0.X-ci**, on all four channels — fossils the store kept from years ago.
Promoting channels cannot fix these: there is no newer revision to promote.
armhf needs a build uploaded for that snap; i386 cannot be built at all (see
[i386](#i386-has-no-snap-and-cannot-have-a-new-one)).
Publishing the **stable** channel is safe for these because the snap performs its
**automatic migrations** on start — the schema upgrades and the one-time
MongoDB 3 → FerretDB v1 migration on amd64 — so an existing installation moving
from an older revision to this one is upgraded in place rather than needing a
manual step.
## Related
- FerretDB v1 fork: https://github.com/wekan/FerretDB
- Snap install: [Install.md](Install.md)
- Snap settings keys: [Supported-settings-keys.md](Supported-settings-keys.md)
- The architecture table in code: `models/lib/snapArchitectures.js`
(tested by `tests/snapArchitectures.test.cjs`)
- Publish every snap to every channel: `releases/snap-release-all-channels.sh`
- Node.js builds per platform: [wekan/node-patches](https://github.com/wekan/node-patches)
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.74-win64.zip](https://github.com/wekan/wekan/releases/download/v10.74/wekan-10.74-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.78-win64.zip](https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-10.74-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// ============================================================================
// Snap names, channels, and the two names every architecture has
// ----------------------------------------------------------------------------
// WeKan publishes three snaps of the same application - `wekan`, and the
// `wekan-ondra` and `wekan-gantt-gpl` variants - and each must reach every
// architecture on every channel.
//
// Two things make this less obvious than "three names times eight arches".
//
// 1. ppc64le and ppc64el are the SAME hardware under two names. The release
// bundles at https://github.com/wekan/wekan are named the way Node.js and the
// kernel name things (`ppc64le`); the Snap Store names it the way Debian does
// (`ppc64el`). Nothing warns when the wrong one is used - an unrecognised
// --arch is simply an architecture the store has never heard of - so the
// rename is written down once, here.
//
// 2. armhf and armv7 are NOT two names for one thing, and must never be mapped
// onto each other. They are the same CPU family built to different
// baselines: node-patches builds `armhf` to the Debian armhf baseline
// (hard-float, VFPv3-D16, assuming NO NEON) so it runs on any ARMv7-A, and
// `armv7` with NEON for boards that have it. The Snap 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 therefore has no snap - see NOT_SNAP_ARCHITECTURES below.
//
// The list is therefore the six architectures snapcraft.yaml actually declares a
// `build-for` for, not the eight the release bundles cover. Both directions are
// unit tested (tests/snapArchitectures.test.cjs).
//
// Pure and dependency-free: the shell scripts read it through node, so there is
// no second copy of this table in bash.
// ============================================================================
// The three snaps. Same application, three store listings.
const SNAP_NAMES = ['wekan', 'wekan-ondra', 'wekan-gantt-gpl'];
// Every channel a revision is released to. `stable` is included deliberately:
// the old helper released to `edge,beta,candidate` only, so a build reached
// three of the four and somebody had to remember the fourth by hand.
const SNAP_CHANNELS = ['stable', 'candidate', 'beta', 'edge'];
// One row per architecture the Snap Store gets, matching snapcraft.yaml's
// `build-for` list exactly. `bundle` is null where the release bundle goes by
// the same name - all but one - so the single rename stands out.
const ARCHITECTURES = [
{ snap: 'amd64', bundle: null },
{ snap: 'arm64', bundle: null },
{ snap: 'armhf', bundle: null }, // 32-bit Raspberry Pi OS
{ snap: 'ppc64el', bundle: 'ppc64le' }, // the store spells POWER the Debian way
{ snap: 'riscv64', bundle: null },
{ snap: 's390x', bundle: null },
];
// Architectures WeKan SHIPS but does not BUILD a snap for, and why.
//
// "Does not build" is not the same as "the store has no column": the store keeps
// whatever was ever uploaded, so an architecture can appear there with an
// ancient revision on every channel long after it stopped being buildable.
// That is what makes these worth writing down rather than inferring from the
// store's own listing.
// Written down because "it is missing" and "it cannot be there" look identical
// from the outside, and somebody re-adding one of these would be repeating a
// mistake that has already been made.
const NOT_SNAP_ARCHITECTURES = {
i386:
'core24 (Ubuntu 24.04) has no i386 port, so snapcraft rejects `build-on: i386` - ' +
'and because that is a PARSE error it failed EVERY architecture\'s snap build, not ' +
'only i386\'s. The STORE still holds old i386 revisions (wekan-ondra has one, at ' +
'version 0.X-ci, sitting on all four channels), so the column exists there and is ' +
'years stale; nothing new can be built for it, and re-releasing what is there would ' +
'only re-publish 0.X-ci. i386 users are served by the .deb and the AppImage.',
armv7:
'the same CPU family as armhf, tuned differently: node-patches builds armhf to the ' +
'Debian armhf baseline (hard-float, VFPv3-D16, NO NEON assumed) so it runs on any ' +
'ARMv7-A, and armv7 with NEON for boards that have it (an ODroid-U3, say). The Snap ' +
'Store has ONE 32-bit ARM architecture and it serves every such device, so it must ' +
'carry the BASELINE build - shipping the NEON one would be an illegal instruction on ' +
'any armhf board without NEON. armv7 ships as a bundle only.',
};
/** Every Snap Store architecture, in a stable order. */
function snapArchitectures() {
return ARCHITECTURES.map(a => a.snap);
}
/** Every release-bundle architecture name, in the same order. */
function bundleArchitectures() {
return ARCHITECTURES.map(a => a.bundle || a.snap);
}
/**
* The Snap Store's name for a bundle architecture.
*
* Accepts either spelling, so a caller that already has the store's name is not
* punished for it - `snapArchOf('ppc64el')` is `ppc64el`. Unknown input returns
* '' rather than being passed through: an architecture nobody recognises must
* not reach `snapcraft release`, where it would be a silent no-op.
*
* @param {string} name
* @return {string} the Snap Store architecture, or '' when it is not one
*/
function snapArchOf(name) {
if (typeof name !== 'string' || !name) return '';
const key = name.trim().toLowerCase();
const row = ARCHITECTURES.find(a => a.snap === key || a.bundle === key);
if (row) return row.snap;
// Names the same hardware also goes by upstream. armv7/armv7l is NOT here:
// it is a different CPU from armhf, not another spelling of it.
const ALIASES = { x64: 'amd64', 'x86_64': 'amd64', aarch64: 'arm64' };
return ALIASES[key] || '';
}
/**
* The release bundle's name for an architecture. Same tolerance in reverse.
* @param {string} name
* @return {string} the bundle architecture, or '' when it is not one
*/
function bundleArchOf(name) {
const snap = snapArchOf(name);
if (!snap) return '';
const row = ARCHITECTURES.find(a => a.snap === snap);
return row.bundle || row.snap;
}
/**
* Every (snap, architecture) pair that has to be released, in a stable order.
* The channels are not part of the pair: one `snapcraft release` call takes them
* all at once, so a revision reaches all four or none.
* @return {Array<{snap: string, arch: string, bundleArch: string}>}
*/
function releaseTargets() {
const targets = [];
SNAP_NAMES.forEach(snap => {
ARCHITECTURES.forEach(a => {
targets.push({ snap, arch: a.snap, bundleArch: a.bundle || a.snap });
});
});
return targets;
}
/**
* Pick the revision to release for one architecture out of `snapcraft
* list-revisions` rows.
*
* Revisions are PER ARCHITECTURE - amd64 revision 42 and arm64 revision 42 are
* different uploads - which is why one revision number can never be released to
* every architecture at once, and why this exists at all.
*
* The highest revision wins, not the first row: the store's ordering is not
* something to depend on. When `version` is given, only that version's rows are
* considered, so a release publishes what it built rather than whatever was
* uploaded last.
*
* @param {Array<{revision: number, arch: string, version: string}>} rows
* @param {string} arch a Snap Store architecture
* @param {string} [version]
* @return {number|null}
*/
function pickRevision(rows, arch, version) {
if (!Array.isArray(rows)) return null;
const want = snapArchOf(arch);
if (!want) return null;
const candidates = rows.filter(r => {
if (!r || snapArchOf(r.arch) !== want) return false;
if (!Number.isFinite(Number(r.revision))) return false;
if (version && String(r.version) !== String(version)) return false;
return true;
});
if (!candidates.length) return null;
return candidates.reduce((best, r) => Math.max(best, Number(r.revision)), -Infinity);
}
/**
* Parse `snapcraft list-revisions` output.
*
* Its columns are `Rev. Uploaded Arch Version Channels`, the header is skipped,
* and a revision with no channels has an empty last column - so the parse keys
* on the leading revision number and takes the next three fields, rather than
* counting columns from the right.
*
* @param {string} text
* @return {Array<{revision: number, uploaded: string, arch: string, version: string, channels: string}>}
*/
function parseRevisions(text) {
if (typeof text !== 'string') return [];
return text
.split('\n')
.map(line => line.trim())
.filter(line => /^\d+\s/.test(line))
.map(line => {
const [revision, uploaded, arch, version, ...rest] = line.split(/\s+/);
return {
revision: Number(revision),
uploaded,
arch,
version,
channels: rest.join(' '),
};
})
.filter(r => r.arch && r.version);
}
module.exports = {
SNAP_NAMES,
SNAP_CHANNELS,
ARCHITECTURES,
NOT_SNAP_ARCHITECTURES,
snapArchitectures,
bundleArchitectures,
snapArchOf,
bundleArchOf,
releaseTargets,
pickRevision,
parseRevisions,
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v10.74.0",
"version": "v10.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v10.74.0",
"version": "v10.78.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v10.74.0",
"version": "v10.78.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v10.74</title>
<title>WeKan REST API v10.78</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.74<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.78<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2005,7 +2005,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.74","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the Line truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.78","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the Line truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v10.74
version: v10.78
description: |
The REST API allows you to control and extend Wekan with ease.
+9
View File
@@ -89,7 +89,16 @@ echo " OK: No Go buildinfo binaries found"
'
echo "=== Step 3: Checks passed - building and pushing multi-arch image to registries ==="
# --provenance=mode=max: full build provenance (SLSA), matching release-all.yml.
# The local --load build above gets none - the docker exporter cannot carry
# attestations - so only this pushing build has it.
#
# The comment is HERE and not among the arguments below: a `#` after a line
# continuation comments out the REST OF THE JOINED LINE, so the command becomes
# `buildx build` with every argument swallowed. `bash -n` accepts it, because it
# is valid syntax - it is just a different command.
${DOCKER} buildx build \
--provenance=mode=max \
--platform linux/amd64,linux/arm64 \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# ferretdb-latest-tag.sh - which version `latest` is right now.
#
# WeKan fetches FerretDB from .../releases/latest/download/ferretdb-<arch>, so a
# security fix published in wekan/FerretDB reaches the next bundle without a
# commit here. The cost of that is that the URL does not say WHICH version was
# fetched, and the release notes have to say it: "which FerretDB did v10.77
# ship" must be answerable a year later, when `latest` is something else.
#
# The extra-architecture job already asked the API and recorded the answer. The
# amd64, arm64, win64, win32, mac-arm64 and mac-x64 jobs passed the literal
# string `latest` instead, so six rows of the v10.77 table - including amd64,
# the platform most people download - had "latest" in the Version column, which
# is the one question the column exists to answer.
#
# Usage: ferretdb-latest-tag.sh
#
# Prints the tag (e.g. v1.48.0) and exits 0. Prints NOTHING and still exits 0
# when it cannot find out: this is for a release note, and no caller should fail
# a build over it. Callers write "${TAG:-latest}", which degrades to exactly
# what they had before.
#
# The answer is cached for the life of the job, so six callers in one job make
# one request rather than six.
set -uo pipefail
cache="${FERRETDB_TAG_CACHE:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ferretdb-latest-tag}"
if [ -s "$cache" ]; then
cat "$cache"
exit 0
fi
api="https://api.github.com/repos/wekan/FerretDB/releases/latest"
# -f so an HTTP error is a failure rather than an error page parsed as JSON.
# The token is used when there is one - an unauthenticated runner shares a
# 60/hour limit with every other job on that IP, and this is the failure that
# put "latest" in the table.
auth=()
if [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then
auth=(-H "Authorization: Bearer ${GH_TOKEN:-$GITHUB_TOKEN}")
fi
body="$(curl -fsSL --retry 3 --retry-delay 2 "${auth[@]}" "$api" 2>/dev/null || true)"
[ -n "$body" ] || exit 0
# Prefer python3 - it is on every runner that builds a bundle - and fall back to
# sed, so a container without it still answers instead of silently saying
# "latest".
tag=""
if command -v python3 >/dev/null 2>&1; then
tag="$(printf '%s' "$body" \
| python3 -c 'import json,sys
try:
print(json.load(sys.stdin).get("tag_name", ""))
except Exception:
pass' 2>/dev/null || true)"
fi
if [ -z "$tag" ]; then
tag="$(printf '%s' "$body" \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)"
fi
# A tag, not a sentence. Anything else means the shape of the answer changed,
# and printing it into a markdown table cell would be worse than printing
# nothing - the caller's ${TAG:-latest} is the honest fallback.
case "$tag" in
v[0-9]*) ;;
*) exit 0 ;;
esac
mkdir -p "$(dirname "$cache")" 2>/dev/null || true
printf '%s' "$tag" > "$cache" 2>/dev/null || true
printf '%s' "$tag"
+21 -7
View File
@@ -14,7 +14,11 @@ set -euo pipefail
files=("$@")
if [ ${#files[@]} -eq 0 ]; then
shopt -s nullglob globstar
files=(provenance/**/*.tsv provenance/*.tsv)
# ONE pattern. With globstar, `**/` matches ZERO or more directories, so
# provenance/**/*.tsv ALREADY covers provenance/*.tsv - and listing both
# matched every top-level file twice, which is why the v10.77 release notes
# printed every row of the table twice.
files=(provenance/**/*.tsv)
fi
rows=""
@@ -24,8 +28,18 @@ for f in "${files[@]}"; do
"
done
# Drop blank lines, then sort by bundle and binary.
rows="$(printf '%s' "$rows" | awk 'NF' | sort -t"$(printf '\t')" -k1,1 -k2,2)"
# Drop blank lines, drop rows that are duplicates of a whole other row, then
# sort by bundle and binary.
#
# Deduplicated on the WHOLE line, not on (bundle, binary): a row is (bundle,
# binary, source, version, checked, sha, url), so an identical line is the same
# FACT recorded twice - by a doubled glob, a retried step, a job that ran again
# - and nothing distinguishes the copies. Two rows that share a bundle and a
# binary but differ anywhere else are NOT that; they are a real disagreement
# about which Node.js went into a bundle, and `sort -u -k1,1 -k2,2` would hide
# one of the two at random. Better shown twice and noticed.
rows="$(printf '%s' "$rows" | awk 'NF && !seen[$0]++' \
| sort -t"$(printf '\t')" -k1,1 -k2,2)"
if [ -z "$rows" ]; then
# Not fatal. The bundles are what the release is; a missing provenance
@@ -41,10 +55,10 @@ echo
echo "Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools."
echo "Which source has a given CPU varies from release to release - nodejs.org"
echo "builds some architectures, unofficial-builds others, and the"
echo "[wekan/node](https://github.com/wekan/node) fork the ones neither of them"
echo "does - and not every source publishes a checksum. This is what went into"
echo "this release, and which downloads were checked against a published"
echo "SHA256."
echo "[wekan/node-patches](https://github.com/wekan/node-patches) build the ones"
echo "neither of them does - and not every source publishes a checksum. This is"
echo "what went into this release, and which downloads were checked against a"
echo "published SHA256."
echo
echo "| Bundle | Binary | From | Version | Checked | SHA256 |"
echo "| --- | --- | --- | --- | --- | --- |"
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
#
# snap-release-all-channels.sh - put every snap, on every architecture, on every
# channel.
#
# WeKan publishes THREE snaps of the same application - wekan, wekan-ondra and
# wekan-gantt-gpl - across SIX Snap Store architectures, and each has to reach
# FOUR channels. That is 72 (snap, architecture, channel) combinations, and the
# helper this replaces released one snap, one revision, to three channels:
#
# snapcraft release wekan $1 edge,beta,candidate
#
# which is wrong three ways at once. It names only `wekan`. It takes ONE revision
# number, and revisions are PER ARCHITECTURE - amd64 revision 42 and arm64
# revision 42 are different uploads - so a single number can only ever be right
# for one of them. And it leaves out `stable`, so somebody had to remember the
# fourth channel by hand, every time.
#
# This resolves the revision per (snap, architecture) from the store itself, so
# the caller never types a revision number, and releases it to all four channels
# in one call - a revision reaches all of them or none.
#
# SIX ARCHITECTURES, NOT EIGHT. The release bundles cover eight; snapcraft.yaml
# declares a `build-for` for six, and the two missing ones are missing for
# reasons, not by oversight:
#
# i386 core24 has no i386 port. `build-on: i386` is a PARSE error, which
# failed EVERY architecture's snap build, not only i386's. Served by
# the .deb and the AppImage instead.
# armv7 the Snap Store has ONE 32-bit ARM architecture, `armhf`, and that is
# the 32-bit Raspberry Pi OS build. armv7 is the ODroid-U3 - a DIFFERENT
# CPU with its own bundle. Publishing it as armhf would hand Raspberry
# Pi users the ODroid build, so it ships as a bundle only.
#
# 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
# (ppc64le); the Snap Store names it the way Debian does (ppc64el). Nothing warns
# when the wrong one is used - an unrecognised --arch is simply an architecture
# the store has never heard of - so the mapping lives in ONE place,
# models/lib/snapArchitectures.js, and this script reads it from there rather
# than keeping a second copy in bash.
#
# Usage:
# releases/snap-release-all-channels.sh # newest revision of each
# releases/snap-release-all-channels.sh 10.75 # only that version's
# releases/snap-release-all-channels.sh --dry-run # print, do not release
# releases/snap-release-all-channels.sh --snap wekan # one snap only
# releases/snap-release-all-channels.sh --arch ppc64le # one architecture (either spelling)
#
# Needs `snapcraft` on PATH and a logged-in store session (`snapcraft login`).
set -uo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version=""
dry_run=0
only_snap=""
only_arch=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--snap) only_snap="${2:-}"; shift 2 ;;
--arch) only_arch="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) version="$1"; shift ;;
esac
done
node_bin="$(command -v node || true)"
if [ -z "$node_bin" ]; then
echo "snap-release-all-channels.sh: node is required (it reads the architecture table" >&2
echo " from models/lib/snapArchitectures.js so there is only one copy of it)." >&2
exit 1
fi
if [ "$dry_run" -eq 0 ] && ! command -v snapcraft >/dev/null 2>&1; then
echo "snap-release-all-channels.sh: snapcraft is not on PATH." >&2
echo " Install it and run 'snapcraft login', or pass --dry-run to see the plan." >&2
exit 1
fi
# The single source of truth, read once.
snaps="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_NAMES.join(" "))')"
arches="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchitectures().join(" "))')"
channels="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_CHANNELS.join(","))')"
# Accept either spelling of --arch, and refuse one that is neither.
if [ -n "$only_arch" ]; then
resolved="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchOf(process.argv[1]))' "$only_arch")"
if [ -z "$resolved" ]; then
echo "snap-release-all-channels.sh: '$only_arch' is not an architecture WeKan builds." >&2
echo " Known: $arches (and their bundle names ppc64le, armv7)." >&2
exit 2
fi
only_arch="$resolved"
fi
echo "Channels: $channels"
echo "Snaps: ${only_snap:-$snaps}"
echo "Architectures: ${only_arch:-$arches}"
echo "Version: ${version:-<newest uploaded>}"
# Say what is NOT being published, every run. "It is missing" and "it cannot
# be there" look identical from the outside, and this is the difference.
"$node_bin" -e 'const n = require(process.argv[1] + "/models/lib/snapArchitectures").NOT_SNAP_ARCHITECTURES;'\
'Object.entries(n).forEach(([a, why]) => console.log("Not a snap: " + a + " - " + why));' "$repo_root"
[ "$dry_run" -eq 1 ] && echo "DRY RUN - nothing will be released."
echo
released=0
skipped=0
failed=0
for snap in $snaps; do
[ -n "$only_snap" ] && [ "$snap" != "$only_snap" ] && continue
# One store round-trip per snap, not one per architecture.
revisions="$(snapcraft list-revisions "$snap" 2>/dev/null)"
if [ -z "$revisions" ]; then
echo " $snap: could not list revisions (not logged in, or no such snap) - skipping"
skipped=$((skipped + 1))
continue
fi
for arch in $arches; do
[ -n "$only_arch" ] && [ "$arch" != "$only_arch" ] && continue
revision="$(printf '%s' "$revisions" | "$node_bin" -e '
const { parseRevisions, pickRevision } = require("'"$repo_root"'/models/lib/snapArchitectures");
let text = "";
process.stdin.on("data", d => { text += d; });
process.stdin.on("end", () => {
const rev = pickRevision(parseRevisions(text), process.argv[1], process.argv[2] || undefined);
process.stdout.write(rev === null ? "" : String(rev));
});
' "$arch" "$version")"
if [ -z "$revision" ]; then
# Not every architecture is built for every release, and a snap that
# has none of an architecture is not an error - it is a fact about
# what was uploaded. Say so and carry on.
echo " $snap $arch: no revision${version:+ for $version} - skipping"
skipped=$((skipped + 1))
continue
fi
echo " $snap $arch: revision $revision -> $channels"
if [ "$dry_run" -eq 1 ]; then
released=$((released + 1))
continue
fi
if snapcraft release "$snap" "$revision" "$channels"; then
released=$((released + 1))
else
# One architecture failing must not stop the other seventeen.
echo " $snap $arch: RELEASE FAILED" >&2
failed=$((failed + 1))
fi
done
done
echo
echo "===== released $released, skipped $skipped, failed $failed ====="
[ "$failed" -eq 0 ]
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1074,
appVersion = 1078,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.74~2026-08-09"),
appMarketingVersion = (defaultText = "10.78~2026-08-09"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+26 -4
View File
@@ -1612,23 +1612,45 @@ WebApp.handlers.post('/api/boards/:boardId/cards/labels', async function(req, re
return;
}
// ONE read for every card, not one per card.
//
// This used to await a getCard per id and then an update per id - at the
// BULK_CARDS_MAX of 500 that is a thousand round-trips, issued one at a time,
// for a single request. The reads are all the same question, so they are one
// `$in` query; the writes genuinely differ (each card merges its own labels),
// so they stay individual, but they are issued TOGETHER instead of each
// waiting for the last.
const cards = await ReactiveCache.getCards(
{ _id: { $in: cardIds }, boardId: paramBoardId, archived: false },
{ fields: { _id: 1, labelIds: 1 } },
);
const cardById = new Map((cards || []).map(card => [card._id, card]));
const updated = [];
const notFound = [];
const writes = [];
// Iterate cardIds, not the query result: the response has to preserve the
// caller's order and report the ids that matched nothing.
for (const cardId of cardIds) {
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId, archived: false });
const card = cardById.get(cardId);
if (!card) {
notFound.push(cardId);
continue;
}
// Merge: keep existing minus removed, then add new ones, de-duplicated.
const merged = mergeLabelIds(card.labelIds, addLabelIds, removeLabelIds);
await Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
writes.push(
Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
),
);
updated.push({ _id: cardId, labelIds: merged });
}
// Every write must land before the 200 says they did.
await Promise.all(writes);
sendJsonResult(res, { code: 200, data: { updated, notFound } });
});
+8 -4
View File
@@ -686,10 +686,15 @@ publishComposite('board', async function(boardId, isArchived) {
//
// The five cursors used to repeat this preamble verbatim; sharing it is also
// what stops the next one from being written without the check.
const _linkedIdsByBoard = new Map();
const visibleLinkedCardIds = async board => {
if (_linkedIdsByBoard.has(board._id)) return _linkedIdsByBoard.get(board._id);
// NOT memoized. An earlier version cached the result per board to spare the
// five cursors their duplicate queries - but publishComposite re-runs a
// child's find() when the parent document changes, and a cache that lives
// for the whole subscription would then serve 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 this helper existed, so computing per call is exactly the
// cost they always had, and it is correct.
const compute = (async () => {
const cardSelector = {
...boardCardScope(board),
@@ -723,7 +728,6 @@ publishComposite('board', async function(boardId, isArchived) {
return (linked || []).filter(c => allowedBoardIds.has(c.boardId)).map(c => c._id);
})();
_linkedIdsByBoard.set(board._id, compute);
return compute;
};
+38 -18
View File
@@ -638,16 +638,44 @@ async function buildSelector(queryParams, userId) {
queryUsers[OPERATOR_MEMBER] = [];
queryUsers[OPERATOR_CREATOR] = [];
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = [];
for (const username of queryParams.getPredicates(OPERATOR_USER)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(OPERATOR_USER, username);
}
// Resolve every username the query names in ONE lookup.
//
// Each `user:`/`member:`/`assignee:`/`creator:` predicate used to do 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. Names that matched nothing are still
// reported per operator, exactly as before, because the operator is what
// tells the user WHERE the unknown name was typed.
const namedUsernames = new Set();
for (const key of [OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
for (const username of queryParams.getPredicates(key)) namedUsernames.add(username);
}
}
const userIdByUsername = new Map();
if (namedUsernames.size) {
const found = await ReactiveCache.getUsers(
{ username: { $in: [...namedUsernames] } },
{ fields: { _id: 1, username: 1 } },
);
(found || []).forEach(user => userIdByUsername.set(user.username, user._id));
}
// The ids a predicate names, and the names it got wrong.
const resolvePredicates = key => {
const ids = [];
for (const username of queryParams.getPredicates(key)) {
const id = userIdByUsername.get(username);
if (id) ids.push(id);
else errors.addNotFound(key, username);
}
return ids;
};
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = resolvePredicates(OPERATOR_USER);
if (users.length) {
selector.$and.push({
$or: [{ members: { $in: users } }, { assignees: { $in: users } }],
@@ -657,15 +685,7 @@ async function buildSelector(queryParams, userId) {
for (const key of [OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
const users = [];
for (const username of queryParams.getPredicates(key)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(key, username);
}
}
const users = resolvePredicates(key);
if (users.length) {
selector[key] = { $in: users };
}
+21 -5
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '10.74'
version: '10.78'
base: core24
confinement: strict
grade: stable
@@ -201,9 +201,25 @@ parts:
# against the noble archive: libssl3t64 is published for amd64, arm64,
# armhf, i386, ppc64el, riscv64 and s390x, and libgoogle-perftools4t64
# for every one of those except i386 - which builds no snap.
#
# libcurl4 was the SAME transition, left behind when those two were
# renamed, and it failed the v10.77 armhf snap the same way and for the
# same reason:
# Stage package not found in part 'mongodb': libcurl4.
# while s390x, ppc64el and riscv64 all built on attempt 1. Checked
# against the noble indices the same way: `libcurl4` is not a real
# package on ANY architecture - the binary is `libcurl4t64` everywhere -
# and on amd64/arm64 it carries `Provides: libcurl4 (= 8.5.0-2ubuntu10)`
# so the old name resolves, while the armhf libcurl4t64 has no Provides
# line at all. That is what makes this class of bug reach exactly one
# architecture and look like flakiness on it.
#
# Every other stage-package below was checked against the noble armhf
# main+universe indices at the same time; libcurl4 was the only one that
# did not resolve.
stage-packages:
- libssl3t64
- libcurl4
- libcurl4t64
- libstemmer0d
- zlib1g
- libsnappy1v5
@@ -353,18 +369,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-10.74-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-10.78-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v10.74/wekan-10.74-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v10.74/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v10.78/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+25 -1
View File
@@ -208,6 +208,7 @@ test('every script in releases/ is reachable from BOTH menus', () => {
'require-binaries.sh': 'release-workflow: called per build job',
'record-provenance.sh': 'release-workflow: called per build job',
'provenance-table.sh': 'release-workflow: called by the release job',
'ferretdb-latest-tag.sh': 'release-workflow: a lookup the build jobs call so\n the provenance table can name the version `latest` resolved to - it prints\n a tag and downloads nothing',
'repack-bundle-for-arch.sh': 'release-workflow: needs ./bundle, docker and '
+ 'QEMU already set up by the build job around it',
'expected-assets.sh': 'release-workflow: prints what a complete release '
@@ -237,7 +238,30 @@ test('the Releases menu is the same list, in the same order, in both scripts', (
// Same entries, same order, in the .bat's dispatch lines - EXCEPT the ones
// marked linux: systemd, ufw, snap and multipass are things Windows cannot do
// at all, so offering them there would be a menu entry that can only fail.
const forWindows = entries.filter(e => e.platform !== 'linux');
// A `!`-prefixed entry is a build.sh FUNCTION, not a script in releases/, so
// there is nothing for the .bat to `call :rel_run`. Windows implements those
// natively instead - `git pull` and `git push` are entries 3 and 4 of its main
// menu, going to :gitpull and :gitpush - and the assertion below checks that
// it really does, rather than letting a function entry silently drop out of
// one menu. Comparing them as scripts is what made this guard fail the moment
// "Update git" was replaced by the two that finish the job.
// A `!` marks an entry build.sh runs itself rather than by executing a file in
// releases/. Most are raw commands, and the .bat runs those the same way -
// those still compare. The exception is an entry naming a build.sh FUNCTION,
// which is shell the .bat has no way to call: Windows implements the same
// thing natively, and the loop below checks that it does.
const isFunction = e =>
e.script.startsWith('!') &&
/^[a-z][a-z0-9_]*$/.test(e.script.slice(1)) &&
new RegExp(`^function ${e.script.slice(1)}\\(\\)`, 'm').test(sh);
const forWindows = entries.filter(e => e.platform !== 'linux' && !isFunction(e));
for (const e of entries.filter(x => isFunction(x) && x.platform !== 'linux')) {
const label = e.script.slice(1).replace(/_/g, ''); // git_pull -> gitpull
assert.ok(new RegExp(`^:${label}\\b`, 'm').test(bat),
`build.sh offers "${e.label}" as a function; build.bat must implement :${label}`);
}
const inBat = [...bat.matchAll(/call :rel_(?:run|cmd) "([^"]+)" "([^"]*)"/g)]
.map(m => ({ what: m[1], prompt: m[2] }));
assert.deepStrictEqual(inBat.map(e => e.what),
+11 -4
View File
@@ -185,10 +185,17 @@ test('the assigned-only member restriction survived the de-duplication', () => {
assert.ok(/cardSelector\.assignees = \{ \$in: \[thisUserId\] \}/.test(helper));
});
test('the helper is memoized per board, so five cursors are not five sets of queries', () => {
const helper = publication.match(/const _linkedIdsByBoard = new Map\(\);[\s\S]*?\n \};/)[0];
assert.ok(/_linkedIdsByBoard\.has\(board\._id\)/.test(helper));
assert.ok(/_linkedIdsByBoard\.set\(board\._id, compute\)/.test(helper));
test('the helper is NOT cached across a cursor re-run', () => {
// It was, briefly. publishComposite re-runs a child's find() when the parent
// document changes, so a cache living for the whole subscription 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 queries before the helper existed, so computing per call is
// the cost they always had - and it is correct.
assert.ok(!/_linkedIdsByBoard/.test(publication),
'no subscription-lifetime cache of the linked card ids');
const helper = publication.match(/const visibleLinkedCardIds = async board => \{[\s\S]*?\n \};/)[0];
assert.ok(/NOT memoized/.test(helper), 'and the reason is written where the next reader is');
});
// ------------------------------------------- no hand-written visibility copies
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// Every image WeKan pushes carries full build provenance, and no build site
// quietly stops doing it.
//
// BuildKit attaches MINIMAL provenance on its own, which is why quay.io shows an
// `unknown on unknown` row beside each real platform - the OCI marker for "not a
// runnable image", so `docker pull` never selects it. Minimal is a build id and
// a timestamp. `mode=max` records what actually answers a supply-chain question:
// which commit, which base image, which build arguments.
//
// The hazard is not getting it wrong once - it is that WeKan pushes images from
// FOUR places, and a fifth added later would silently fall back to the default.
// So this checks every buildx/build-push-action call site in the repository,
// found by searching rather than from a list.
//
// Run: node tests/dockerProvenance.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Where images are built. Searched for, not listed, so a new one is found.
function buildSites() {
const files = [];
const walk = dir => {
fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true }).forEach(e => {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) return;
if (!/\.(ya?ml|sh)$/.test(e.name)) return;
const src = read(rel);
if (/buildx build|build-push-action/.test(src)) files.push(rel);
});
};
walk('.github/workflows');
walk('releases');
return files;
}
// One entry per `docker buildx build` invocation: the whole command, joined.
function buildxCommands(src) {
const out = [];
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
// The COMMAND, not a comment that mentions it.
if (!/buildx build/.test(lines[i])) continue;
if (/^\s*#/.test(lines[i])) continue;
let cmd = lines[i];
let j = i;
while (/\\\s*$/.test(lines[j]) && j + 1 < lines.length) {
j += 1;
cmd += ' ' + lines[j].trim();
}
out.push(cmd);
}
return out;
}
test('every pushing buildx build asks for full provenance', () => {
buildSites().forEach(file => {
buildxCommands(read(file)).forEach(cmd => {
// Only a build that PUSHES can carry an attestation. The local --load
// build is a check, and the docker exporter cannot carry one at all.
if (!/--push/.test(cmd)) return;
assert.ok(/--provenance=mode=max/.test(cmd),
`${file}: a pushing buildx build without --provenance=mode=max:\n ${cmd.slice(0, 120)}`);
});
});
});
test('negative: the local --load build does NOT ask for it', () => {
// The docker exporter cannot carry attestations; asking would fail the build.
const cmds = buildxCommands(read('releases/docker-build.sh'));
const load = cmds.filter(c => /--load/.test(c));
assert.ok(load.length >= 1, 'the check build is still there');
load.forEach(c => assert.ok(!/--provenance/.test(c),
'the --load build must not ask for provenance'));
});
test('every build-push-action sets provenance: mode=max', () => {
buildSites().filter(f => f.endsWith('.yml') || f.endsWith('.yaml')).forEach(file => {
const src = read(file);
if (!/build-push-action/.test(src)) return;
assert.ok(/provenance:\s*mode=max/.test(src),
`${file}: build-push-action without provenance: mode=max`);
});
});
test('THE TRAP: no comment sits among a continued command\'s arguments', () => {
// A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
// docker buildx build \
// # explain the next flag
// --provenance=mode=max \
// --platform ... \
// becomes `docker buildx build` with EVERY argument swallowed. `bash -n`
// accepts it - it is valid syntax, just a different command - and this was
// written exactly that way once before the test existed.
// Every build site, not only .sh: a YAML `run:` block is a shell script, and
// that is exactly where this was written the wrong way the first time.
buildSites().forEach(file => {
const lines = read(file).split('\n');
for (let i = 0; i < lines.length - 1; i++) {
if (!/\\\s*$/.test(lines[i])) continue;
// A continuation INSIDE a comment is not a command: a comment block that
// quotes a multi-line shell example ends its lines with `\` too, and every
// line of it starts with `#`. Only a continued COMMAND can be broken this
// way.
if (/^\s*#/.test(lines[i])) continue;
assert.ok(!/^\s*#/.test(lines[i + 1]),
`${file}:${i + 2}: a comment after a line continuation swallows the rest of the command`);
}
});
});
test('the arguments really do survive the joined command', () => {
// The property the trap above destroys, asserted directly: the pushing build
// still carries its platforms and its tags.
const cmd = buildxCommands(read('releases/docker-build.sh')).find(c => /--push/.test(c));
assert.ok(cmd, 'the pushing build is found');
assert.ok(/--platform linux\/amd64,linux\/arm64/.test(cmd), 'it still names its platforms');
assert.ok(/-t wekanteam\/wekan:/.test(cmd), 'and its tags');
assert.ok(/--provenance=mode=max/.test(cmd));
});
test('SBOM stays off, deliberately', () => {
// It is opt-in, and it is not free: an SBOM enumerates every OS package and
// npm dependency, so the attestation grows from ~10 KiB to megabytes per
// platform. Turning it on is a decision, not a default to drift into - this
// fails if one appears without the comment being revisited.
buildSites().forEach(file => {
const src = read(file);
assert.ok(!/--sbom=true|sbom:\s*true/.test(src),
`${file}: SBOM was enabled - see tests/dockerProvenance.test.cjs about the size`);
});
});
test('all four pushing sites are found, so none is missed', () => {
const sites = buildSites();
const pushing = sites.reduce((n, f) => {
const src = read(f);
const buildx = buildxCommands(src).filter(c => /--push/.test(c)).length;
const action = /build-push-action/.test(src) ? 1 : 0;
return n + buildx + action;
}, 0);
assert.strictEqual(pushing, 4,
`expected 4 pushing build sites, found ${pushing} - if that is a real change, ` +
'give the new one --provenance=mode=max and update this count');
});
console.log(`\n${passed} tests passed`);
+148
View File
@@ -0,0 +1,148 @@
'use strict';
// Every bundled binary is fetched as the NEWEST one, everywhere it is fetched.
//
// 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 - `stdlib 1.25.9` wanting 1.25.11, `golang.org/x/sys v0.38.0`
// wanting 0.44.0 - in the FerretDB binary baked into the bundle. 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.
//
// Run: node tests/newestBundledBinaries.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// The repositories WeKan takes binaries from. Node.js is deliberately absent:
// its version is RESOLVED per architecture by releases/resolve-node-source.sh,
// which is asked about the MAJOR and answers with that CPU's newest build from
// whichever of the three sources has one - a different mechanism with the same
// effect, and it has its own tests.
const UPSTREAM = ['wekan/FerretDB', 'wekan/mongo-tools-patches'];
// Files that fetch things. Searched for rather than listed.
function sourceFiles() {
const out = [];
const skip = new Set(['node_modules', '.git', '.tools', '.meteor', 'public']);
const walk = dir => {
for (const e of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (skip.has(e.name)) continue;
const rel = dir === '.' ? e.name : `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel);
else if (/\.(sh|ya?ml)$|^Dockerfile/.test(e.name)) out.push(rel);
}
};
walk('.');
return out;
}
// Every release URL for the upstreams above, with the file it came from.
function releaseUrls() {
const found = [];
sourceFiles().forEach(file => {
const src = fs.readFileSync(path.join(repoRoot, file), 'utf8');
src.split('\n').forEach((line, i) => {
UPSTREAM.forEach(repo => {
const re = new RegExp(`${repo}/releases/([^"'\\s)]*)`, 'g');
let m;
while ((m = re.exec(line)) !== null) {
found.push({ file, line: i + 1, repo, rest: m[1], text: line.trim() });
}
});
});
});
return found;
}
test('there ARE such URLs, so this test is checking something', () => {
const urls = releaseUrls();
assert.ok(urls.length >= 5, `expected several release URLs, found ${urls.length}`);
});
test('every FerretDB and mongo-tools URL asks for the newest build', () => {
releaseUrls().forEach(u => {
// Three acceptable shapes:
// latest/download/<asset> - the newest release's asset
// ${VAR}/... - an override whose DEFAULT is latest, checked
// separately below
// prose in a comment - a link to the releases page, not a fetch
// `latest/download/<asset>` is a fetch; a bare `latest` is the API
// endpoint that ASKS which version latest is - which release-all.yml does
// to record it in the provenance table. Both are the newest, neither pins.
const isLatest = u.rest === 'latest' || u.rest.startsWith('latest/');
const isOverridable = /^\$\{?\$?\{?[A-Z_]/.test(u.rest) || /\$\{?\$?\{?[A-Z_]+\}?/.test(u.rest);
const isProse = u.text.trim().startsWith('#') || /^\)?[,.]?$/.test(u.rest);
assert.ok(isLatest || isOverridable || isProse,
`${u.file}:${u.line} fetches a FIXED version - it will stop getting ` +
`security fixes and nothing will fail:\n ${u.text.slice(0, 110)}`);
});
});
test('the overridable one defaults to latest', () => {
// docker-compose lets an operator pin a FerretDB for their own reasons
// (FERRETDB_RELEASE=download/v1.24.2). The DEFAULT must still be newest, or
// every operator who does not set it is silently frozen.
const composes = sourceFiles().filter(f => /^docker-compose.*\.ya?ml$/.test(f));
assert.ok(composes.length >= 1, 'the compose files are found');
composes.forEach(file => {
const src = fs.readFileSync(path.join(repoRoot, file), 'utf8');
if (!/FERRETDB_RELEASE/.test(src)) return;
assert.ok(/FERRETDB_RELEASE:\s*\$\{FERRETDB_RELEASE:-latest\/download\}/.test(src),
`${file}: FERRETDB_RELEASE must default to latest/download`);
});
});
test('negative: no URL names a version number directly', () => {
// The shape that would silently freeze a component: .../releases/download/v1.2.3/...
releaseUrls().forEach(u => {
if (u.text.trim().startsWith('#')) return; // a comment may show an example
assert.ok(!/^download\/v[0-9]/.test(u.rest),
`${u.file}:${u.line} pins a version:\n ${u.text.slice(0, 110)}`);
});
});
test('Node.js is resolved per architecture, not fetched from a fixed URL', () => {
// A different mechanism with the same effect: resolve-node-source.sh is asked
// about the MAJOR and answers with that CPU's newest build from whichever of
// nodejs.org, unofficial-builds or node-patches has one.
const resolver = 'releases/resolve-node-source.sh';
assert.ok(fs.existsSync(path.join(repoRoot, resolver)), `${resolver} exists`);
const src = fs.readFileSync(path.join(repoRoot, resolver), 'utf8');
assert.ok(/nodejs\.org/.test(src) && /unofficial-builds/.test(src) && /node-patches/.test(src),
'it still knows all three sources');
});
test('the provenance table is what makes a `latest` build reproducible after the fact', () => {
// `latest` means rebuilding an old release would embed a different FerretDB
// than it shipped with. That is a deliberate trade - security fixes arrive
// without a commit - and what makes it safe is that every release RECORDS the
// versions and SHA256s it actually shipped.
const rec = 'releases/record-provenance.sh';
assert.ok(fs.existsSync(path.join(repoRoot, rec)),
`${rec} must exist: it is what answers "which FerretDB did v10.77 ship?"`);
const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
assert.ok(/\| Platform \| Binary \| From \| Version \| SHA256 \|/.test(changelog),
'and the CHANGELOG carries the table it produces');
});
console.log(`\n${passed} tests passed`);
+336
View File
@@ -0,0 +1,336 @@
'use strict';
// The release notes must say what actually went into each bundle - once, for
// every platform, with a version rather than the word "latest".
//
// The v10.77 notes got all three wrong at the same time, and each failure was
// silent: nothing errored, the table was simply not what it claimed to be.
//
// Every row 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.
//
// amd64 was MISSING, 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/ - "No files
// were found with the provided path: provenance/". Every other build job
// records from the workspace root, which is why only amd64 was affected.
//
// 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. "Which FerretDB did v10.77 ship" is the
// one question that column exists to answer.
//
// Run: node tests/provenanceTable.test.cjs
const assert = require('assert');
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const TABLE = 'releases/provenance-table.sh';
const RECORD = 'releases/record-provenance.sh';
const RESOLVER = 'releases/ferretdb-latest-tag.sh';
const WORKFLOW = '.github/workflows/release-all.yml';
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const TAB = '\t';
const row = (bundle, what, src, version, checked, sha, url) =>
[bundle, what, src, version, checked, sha, url].join(TAB);
// Run provenance-table.sh over a directory of .tsv files and return its output.
function tableFor(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-prov-'));
try {
fs.mkdirSync(path.join(dir, 'provenance'), { recursive: true });
Object.entries(files).forEach(([name, lines]) => {
const p = path.join(dir, 'provenance', name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, lines.join('\n') + '\n');
});
return execFileSync('bash', [path.join(repoRoot, TABLE)], {
cwd: dir, encoding: 'utf8',
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const bodyRows = out => out.split('\n')
.filter(l => l.startsWith('| ') && !/^\| (Bundle|-)/.test(l));
// ───────────────────────────────────────────── every row appears exactly once
test('THE BUG: each recorded row is printed once, not twice', () => {
const out = tableFor({
'amd64.tsv': [
row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'https://nodejs.org/x'),
row('amd64', 'FerretDB', 'wekan/FerretDB', 'v1.48.0', 'verified', 'bb22', 'https://gh/f'),
],
's390x.tsv': [
row('s390x', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'cc33', 'https://nodejs.org/s'),
],
});
assert.strictEqual(bodyRows(out).length, 3,
'three rows recorded, so three rows printed');
});
test('...and the glob that read every file twice is still ONE pattern', () => {
// The row-counting test above passes either way now, because the dedup added
// with this fix would swallow a doubled glob - which is what defence in depth
// is for, and also why the cause needs its own guard or it can come back
// invisibly. With globstar, `provenance/**/*.tsv` already matches
// `provenance/*.tsv`; listing both reads every top-level file twice.
const src = read(TABLE);
const globLine = src.split('\n').find(l => /files=\(provenance/.test(l));
assert.ok(globLine, 'the default glob is still there');
const patterns = globLine.replace(/.*files=\(|\).*/g, '').trim().split(/\s+/);
assert.deepStrictEqual(patterns, ['provenance/**/*.tsv'],
'one pattern: `**/` matches zero or more directories, so it covers both');
assert.ok(/shopt -s nullglob globstar/.test(src),
'and globstar is on, or `**` is just `*` and nested files are missed');
});
test('a file in a SUBDIRECTORY is still found - the glob covers both', () => {
// download-artifact can land each artifact in its own directory. Collapsing to
// one glob must not lose those.
const out = tableFor({
'amd64.tsv': [row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u')],
'provenance-arm64/arm64.tsv': [
row('arm64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'bb22', 'u'),
],
});
const rows = bodyRows(out);
assert.strictEqual(rows.length, 2);
assert.ok(rows.some(r => r.startsWith('| amd64 ')), 'the top-level file');
assert.ok(rows.some(r => r.startsWith('| arm64 ')), 'and the nested one');
});
test('the same fact recorded twice collapses to one row', () => {
// A retried step, or a job that ran again, records an identical line. It is
// one fact and nothing tells the copies apart.
const same = row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u');
const out = tableFor({ 'a.tsv': [same], 'b.tsv': [same] });
assert.strictEqual(bodyRows(out).length, 1);
});
test('NEGATIVE: a real disagreement is NOT collapsed', () => {
// Two rows sharing bundle and binary but differing elsewhere are not a
// duplicate - they are two answers to "which Node.js went into amd64", and
// hiding one at random is worse than showing both. This is why the dedup is
// on the whole line and not on the sort key.
const out = tableFor({
'a.tsv': [
row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u1'),
row('amd64', 'Node.js', 'wekan/node-patches', 'v24.19.1', 'verified', 'bb22', 'u2'),
],
});
assert.strictEqual(bodyRows(out).length, 2,
'both must survive, so the disagreement is visible');
});
test('rows are grouped by bundle, then by binary', () => {
const out = tableFor({
'x.tsv': [
row('s390x', 'Node.js', 'nodejs.org', 'v1', 'verified', 'a', 'u'),
row('amd64', 'Node.js', 'nodejs.org', 'v1', 'verified', 'b', 'u'),
row('amd64', 'FerretDB', 'wekan/FerretDB', 'v1', 'verified', 'c', 'u'),
],
});
const bundles = bodyRows(out).map(r => r.split('|')[1].trim());
assert.deepStrictEqual(bundles, ['amd64', 'amd64', 's390x']);
const binaries = bodyRows(out).slice(0, 2).map(r => r.split('|')[2].trim());
assert.deepStrictEqual(binaries, ['FerretDB', 'Node.js']);
});
test('a source with no checksum says so instead of showing a blank', () => {
const out = tableFor({
'x.tsv': [row('s390x', 'Node.js', 'nodejs.org', 'v1', 'no checksum published', '-', 'u')],
});
const r = bodyRows(out)[0];
assert.ok(/no checksum published/.test(r));
assert.ok(/\| — \|/.test(r), 'and an em dash, not an empty cell');
assert.ok(!/`-`/.test(r), 'not a literal dash in backticks');
});
test('no rows at all is a note, not a failed release', () => {
const out = tableFor({});
assert.ok(/no build job recorded/.test(out));
assert.ok(!/\| Bundle \|/.test(out), 'and no empty table');
});
test('the table names the right fork', () => {
// It said [wekan/node](https://github.com/wekan/node); the binaries come from
// wekan/node-patches, which is also what the rows themselves link to.
const src = read(TABLE);
assert.ok(/wekan\/node-patches/.test(src));
assert.ok(!/github\.com\/wekan\/node\)/.test(src),
'the old wrong link is gone');
});
// ─────────────────────────────────────── amd64 records where the upload looks
test('THE BUG: amd64 records into the workspace, not into .build', () => {
const src = read(WORKFLOW);
// The amd64 zip step, from its `cd .build` to the end of the run block.
const step = src.slice(src.indexOf('- name: Create amd64 zip'));
const upTo = step.slice(0, step.indexOf('- uses: actions/upload-artifact'));
assert.ok(/cd \.build/.test(upTo), 'the step still cds, which is what made this bite');
const calls = upTo.split('\n').filter(l => /PROVENANCE_FILE=/.test(l));
assert.strictEqual(calls.length, 2, 'Node.js and FerretDB are both recorded');
calls.forEach(l => assert.ok(/GITHUB_WORKSPACE/.test(l),
`a relative provenance path inside "cd .build" writes where nothing reads:\n ${l.trim()}`));
assert.ok(/mkdir -p "\$GITHUB_WORKSPACE\/provenance"/.test(upTo),
'and the directory is made there too');
});
test('every build job that records also uploads what it recorded', () => {
const src = read(WORKFLOW);
const recorded = [...src.matchAll(/PROVENANCE_FILE=\S*?provenance\/([a-z0-9-]+)\.tsv/g)]
.map(m => m[1]);
assert.ok(recorded.length >= 6, `expected several recording sites, found ${recorded.length}`);
new Set(recorded).forEach(arch => {
assert.ok(src.includes(`name: provenance-${arch}`),
`${arch} records provenance but no job uploads it`);
});
});
// ──────────────────────────────────────────── the version is a version
test('THE BUG: no site records the literal string "latest" as the version', () => {
const src = read(WORKFLOW);
assert.ok(!/'wekan\/FerretDB' 'latest'/.test(src),
'a Version column reading "latest" answers nothing a year later');
});
test('every FerretDB provenance call resolves the tag first', () => {
const src = read(WORKFLOW);
const lines = src.split('\n');
const calls = lines
.map((l, i) => ({ l, i }))
.filter(x => /'FerretDB'/.test(x.l) && /wekan\/FerretDB/.test(x.l));
assert.ok(calls.length >= 6, `expected the FerretDB rows, found ${calls.length}`);
calls.forEach(c => {
assert.ok(/\$\{FERRET_TAG:-latest\}/.test(c.l),
`${WORKFLOW}:${c.i + 1} does not use the resolved tag:\n ${c.l.trim()}`);
// ...and FERRET_TAG has to have been set just above it.
const above = lines.slice(Math.max(0, c.i - 4), c.i).join('\n');
assert.ok(/FERRET_TAG="\$\(bash \S*ferretdb-latest-tag\.sh\)"/.test(above),
`${WORKFLOW}:${c.i + 1} uses FERRET_TAG without resolving it above`);
});
});
test('there is ONE implementation of "which version is latest"', () => {
const src = read(WORKFLOW);
assert.ok(!/api\.github\.com\/repos\/wekan\/FerretDB/.test(src),
'the API call is inlined again; it belongs in the resolver only');
assert.ok(fs.existsSync(path.join(repoRoot, RESOLVER)), `${RESOLVER} exists`);
});
test('the resolver never fails its caller', () => {
// It feeds a release note. A rate-limited or offline runner must degrade to
// the old behaviour, not fail a build that produced a perfectly good bundle.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-ftag-'));
try {
const out = execFileSync('bash', [path.join(repoRoot, RESOLVER)], {
encoding: 'utf8',
env: {
...process.env,
FERRETDB_TAG_CACHE: path.join(dir, 'cache'),
http_proxy: 'http://127.0.0.1:9',
https_proxy: 'http://127.0.0.1:9',
HTTPS_PROXY: 'http://127.0.0.1:9',
},
});
assert.strictEqual(out.trim(), '', 'it prints nothing when it cannot find out');
} catch (e) {
assert.fail(`the resolver exited non-zero with no network: ${e.status}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the resolver refuses to print anything that is not a tag', () => {
// Whatever it prints goes into a markdown cell. An error page, an HTML body
// or a rate-limit message must fall through to the caller's :-latest.
const src = read(RESOLVER);
assert.ok(/v\[0-9\]\*\)/.test(src), 'the shape of the answer is checked');
assert.ok(/GITHUB_TOKEN|GH_TOKEN/.test(src),
'and it authenticates, so a shared 60/hour limit is not what makes it fail');
});
// ─────────────────────────────────── the record script itself
test('a recorded row has the seven fields the table reads', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-rec-'));
try {
const file = path.join(dir, 'p.tsv');
execFileSync('bash', [path.join(repoRoot, RECORD),
'amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'https://x', 'abc123'],
{ env: { ...process.env, PROVENANCE_FILE: file }, encoding: 'utf8' });
const fields = fs.readFileSync(file, 'utf8').trim().split(TAB);
assert.strictEqual(fields.length, 7);
assert.deepStrictEqual(fields.slice(0, 6),
['amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'abc123']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an omitted checksum is recorded as unpublished, not as verified', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-rec-'));
try {
const file = path.join(dir, 'p.tsv');
execFileSync('bash', [path.join(repoRoot, RECORD),
'amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'https://x'],
{ env: { ...process.env, PROVENANCE_FILE: file }, encoding: 'utf8' });
const fields = fs.readFileSync(file, 'utf8').trim().split(TAB);
assert.strictEqual(fields[4], 'no checksum published');
assert.strictEqual(fields[5], '-');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ────────────────────────── a diagnostic upload cannot fail a good build
test('THE BUG: snapcraft build logs are renamed before they are uploaded', () => {
// snapcraft names a remote-build log after the recipe and an ISO timestamp:
// snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
// and upload-artifact REFUSES a colon, because NTFS cannot hold one. That
// failed the whole job for s390x, ppc64el and riscv64 in v10.77 AFTER all
// three snaps had built and downloaded on attempt 1 - "Remote build s390x
// succeeded on attempt 1" is in the same log as "FAILED snap-launchpad
// s390x".
const src = read(WORKFLOW);
const step = src.slice(src.indexOf('mkdir -p snap-logs'));
const upTo = step.slice(0, step.indexOf('- name: Push'));
assert.ok(/\$\{base\/\/:\/-\}/.test(upTo),
'the colons in the log name must be replaced, or the upload refuses the file');
assert.ok(!/cp -f snapcraft-wekan-\*\.txt snap-logs\/\s/.test(upTo),
'the plain copy that kept the colons is gone');
});
test('and that upload can never fail the job it is diagnosing', () => {
const src = read(WORKFLOW);
const i = src.indexOf('name: snap-launchpad-logs-');
assert.ok(i > 0, 'the log upload step is still there');
// Look back at the step this `with:` belongs to.
const step = src.slice(src.lastIndexOf('- uses: actions/upload-artifact', i), i);
assert.ok(/continue-on-error:\s*true/.test(step),
'the snap is the deliverable; saving its log must not be able to fail it');
});
console.log(`\n${passed} tests passed`);
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// Three round-trip counts that were linear in the input, and are not any more.
//
// None of these was a wrong answer - they were the right answer asked one
// document at a time. A query per item is invisible on a developer's board with
// four cards and is the whole cost of the request on a real one, because each
// await is a full round-trip that starts only when the last one finished.
//
// Run: node tests/queryBatching.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const cards = read('server/models/cards.js');
const search = read('server/publications/cards.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ───────────────────────────── the bulk label endpoint (up to 500 cards) ─────
const bulkLabels = cards.match(
/\/\/ ONE read for every card, not one per card\.[\s\S]*?await Promise\.all\(writes\);/,
);
test('the bulk label endpoint reads every card in ONE query', () => {
assert.ok(bulkLabels, 'the batched block is there');
assert.ok(
/getCards\(\s*\{ _id: \{ \$in: cardIds \}, boardId: paramBoardId, archived: false \}/.test(bulkLabels[0]),
'one $in read for the whole batch',
);
assert.ok(/new Map\(\(cards \|\| \[\]\)\.map\(card => \[card\._id, card\]\)\)/.test(bulkLabels[0]),
'and the result is indexed, not searched per id');
});
test('negative: no per-card read remains in that handler', () => {
assert.ok(!/for \(const cardId of cardIds\) \{[\s\S]{0,200}?await ReactiveCache\.getCard\(/.test(bulkLabels[0]),
'a getCard inside the loop is the thing this replaced');
});
test('the writes are issued together, not one waiting for the last', () => {
// They genuinely differ per card - each merges its own labelIds - so they
// stay individual updates. What changed is that they no longer serialize.
assert.ok(/writes\.push\(\s*Cards\.direct\.updateAsync\(/.test(bulkLabels[0]));
assert.ok(/await Promise\.all\(writes\);/.test(bulkLabels[0]),
'and every one must land before the 200 says they did');
});
test('the response still preserves the caller\'s order and reports misses', () => {
// The loop iterates cardIds, NOT the query result: a batch read comes back in
// the database's order and without the ids that matched nothing, so iterating
// it would silently reorder `updated` and lose `notFound`.
assert.ok(/for \(const cardId of cardIds\) \{/.test(bulkLabels[0]));
assert.ok(/const card = cardById\.get\(cardId\);/.test(bulkLabels[0]));
assert.ok(/if \(!card\) \{\s*\n\s*notFound\.push\(cardId\);/.test(bulkLabels[0]));
});
test('the cap that bounds the batch is still there', () => {
assert.ok(/const BULK_CARDS_MAX = \d+;/.test(cards));
const max = Number(cards.match(/const BULK_CARDS_MAX = (\d+);/)[1]);
assert.ok(max > 0 && max <= 1000, `the cap is ${max}`);
});
test('negative: the bulk DELETE is deliberately NOT batched', () => {
// Its per-card work is real - cardRemover runs the sub-item hooks and each
// card gets its own activity - so batching the reads would save one query in
// a loop that does far more than query. Left alone on purpose.
const del = cards.match(/const deleted = \[\];[\s\S]*?for \(const cardId of cardIds\) \{[\s\S]*?await cardRemover\(/);
assert.ok(del, 'the delete handler still reads per card, and that is fine');
});
// ─────────────────────────────── global search: one lookup for every name ────
const batchBlock = search.match(
/\/\/ Resolve every username the query names in ONE lookup\.[\s\S]*?const resolvePredicates = key => \{[\s\S]*?\n \};/,
);
test('global search resolves every named username in ONE query', () => {
assert.ok(batchBlock, 'the batched block is there');
assert.ok(/getUsers\(\s*\{ username: \{ \$in: \[\.\.\.namedUsernames\] \} \}/.test(batchBlock[0]),
'one $in over every name the query mentions');
assert.ok(/fields: \{ _id: 1, username: 1 \}/.test(batchBlock[0]),
'and only the two fields it needs');
});
test('the names are collected across all four operators before asking', () => {
assert.ok(/for \(const key of \[OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR\]\)/
.test(batchBlock[0]));
assert.ok(/new Set\(\)/.test(batchBlock[0]),
'a name typed under two operators is one lookup, not two');
});
test('negative: no per-username query remains', () => {
assert.ok(!/for \(const username of queryParams\.getPredicates\([^)]*\)\) \{\s*\n\s*const user = await ReactiveCache\.getUser\(/.test(search),
'an awaited getUser per predicate is the thing this replaced');
});
test('an unknown name is still reported against the operator it was typed under', () => {
// This is what makes it worth keeping a per-operator step at all: "ann is not
// a user" is not useful if it does not say WHERE ann was typed.
assert.ok(/errors\.addNotFound\(key, username\)/.test(batchBlock[0]));
assert.ok(/resolvePredicates\(OPERATOR_USER\)/.test(search));
['OPERATOR_MEMBER', 'OPERATOR_ASSIGNEE', 'OPERATOR_CREATOR'].forEach(op => {
assert.ok(new RegExp(`\\[${'OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR'}\\]`).test(search),
`${op} still goes through the same resolver`);
});
});
test('the selectors built from the resolved ids are unchanged', () => {
// user: matches members OR assignees; the other three are their own field.
assert.ok(/\$or: \[\{ members: \{ \$in: users \} \}, \{ assignees: \{ \$in: users \} \}\]/.test(search));
assert.ok(/selector\[key\] = \{ \$in: users \};/.test(search));
});
// ─────────────────────────────────── FerretDB: the $or that never pushed down ─
test('FerretDB pushes a top-level $or down when every branch can be', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) {
console.log(' -- .tools/FerretDB not cloned; skipping the Go side');
return;
}
const src = fs.readFileSync(go, 'utf8');
assert.ok(/func pushdownOrCondition/.test(src));
assert.ok(/if k == "\$or" \{/.test(src), 'the top-level $or is no longer skipped outright');
// ALL OR NOTHING: an OR that drops a branch removes rows the Go filter never
// sees, which is the opposite of every other pushdown's superset contract.
assert.ok(/if !condOK \{\s*\n\s*return "", nil, false\s*\n\s*\}/.test(src),
'one unpushable branch must refuse the whole $or');
assert.ok(/\$and, another \$or/.test(src) || /nested operator inside a branch/.test(src),
'a nested operator branch is refused, not silently dropped');
});
test('negative: the other top-level operators still stay in Go', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) return;
const src = fs.readFileSync(go, 'utf8');
const block = src.match(/if strings\.HasPrefix\(k, "\$"\) \{[\s\S]*?continue\n\t\t\}/)[0];
assert.ok(/k == "\$or"/.test(block), 'only $or learned this');
assert.ok(!/\$and|\$nor|\$not/.test(block), 'nothing else was quietly included');
assert.ok(/continue/.test(block), 'and everything else still falls through to the Go filter');
});
console.log(`\n${passed} tests passed`);
+265
View File
@@ -0,0 +1,265 @@
'use strict';
// Which snaps, on which architectures, to which channels - and the two
// architectures that are NOT the two names of one thing.
//
// WeKan publishes three snaps of the same application across six Snap Store
// architectures, and each has to reach four channels. The helper this replaces
// released ONE snap, ONE revision, to THREE channels - and a revision number is
// per architecture, so a single number could only ever be right for one of them.
//
// The mapping is where the real hazard is, and most of this suite is about it:
//
// ppc64le and ppc64el ARE the same hardware under two names.
// armhf and armv7 are NOT. armhf is 32-bit Raspberry Pi OS; armv7 is
// the ODroid-U3. Mapping one onto the other would hand
// Raspberry Pi users the ODroid build, which is why the
// negative test below exists at all.
//
// Run: node tests/snapArchitectures.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
SNAP_NAMES,
SNAP_CHANNELS,
NOT_SNAP_ARCHITECTURES,
snapArchitectures,
bundleArchitectures,
snapArchOf,
bundleArchOf,
releaseTargets,
pickRevision,
parseRevisions,
} = require('../models/lib/snapArchitectures');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ───────────────────────────────────────────────── the three snaps, four channels
test('all three snaps are published, not just wekan', () => {
assert.deepStrictEqual(SNAP_NAMES, ['wekan', 'wekan-ondra', 'wekan-gantt-gpl']);
});
test('all FOUR channels, stable included', () => {
// The old helper released to edge,beta,candidate - so a build reached three of
// the four and somebody had to remember stable by hand, every time.
assert.deepStrictEqual([...SNAP_CHANNELS].sort(),
['beta', 'candidate', 'edge', 'stable']);
});
test('every snap gets every architecture', () => {
const targets = releaseTargets();
assert.strictEqual(targets.length, SNAP_NAMES.length * snapArchitectures().length);
SNAP_NAMES.forEach(snap => {
const forSnap = targets.filter(t => t.snap === snap).map(t => t.arch);
assert.deepStrictEqual(forSnap, snapArchitectures(), `${snap} is missing architectures`);
});
});
// ────────────────────────────────────────────── ppc64le and ppc64el: one thing
test('ppc64le (bundle) and ppc64el (store) are the same architecture', () => {
assert.strictEqual(snapArchOf('ppc64le'), 'ppc64el');
assert.strictEqual(snapArchOf('ppc64el'), 'ppc64el', 'the store name maps to itself');
assert.strictEqual(bundleArchOf('ppc64el'), 'ppc64le');
assert.strictEqual(bundleArchOf('ppc64le'), 'ppc64le');
});
test('it is the ONLY architecture whose two names differ', () => {
const renamed = snapArchitectures().filter(a => bundleArchOf(a) !== a);
assert.deepStrictEqual(renamed, ['ppc64el'],
'a second rename would need its own reason written down');
});
// ─────────────────────────────── armhf and armv7: two things, and must stay two
test('NEGATIVE, THE IMPORTANT ONE: armv7 is not another name for armhf', () => {
// armhf is 32-bit Raspberry Pi OS. armv7 is the ODroid-U3. They are different
// CPUs with different bundles, and mapping one onto the other would publish
// the ODroid build to Raspberry Pi users.
assert.strictEqual(snapArchOf('armhf'), 'armhf');
assert.strictEqual(snapArchOf('armv7'), '',
'armv7 must NOT resolve to a Snap Store architecture');
assert.strictEqual(snapArchOf('armv7l'), '',
'nor its Node.js spelling');
assert.strictEqual(bundleArchOf('armhf'), 'armhf',
"armhf's bundle is armhf, not armv7");
});
test('armv7 is recorded as not-a-snap, with the reason', () => {
assert.ok(NOT_SNAP_ARCHITECTURES.armv7, 'it is written down, not merely absent');
// The reason is NEON, not the CPU generation: node-patches builds armhf to the
// Debian baseline (VFPv3-D16, no NEON) and armv7 with NEON. The store's single
// 32-bit ARM architecture must carry the baseline, or the snap is an illegal
// instruction on any board without NEON.
assert.ok(/NEON/.test(NOT_SNAP_ARCHITECTURES.armv7));
assert.ok(/baseline/i.test(NOT_SNAP_ARCHITECTURES.armv7),
'and says which build the store must carry');
assert.ok(/illegal instruction/i.test(NOT_SNAP_ARCHITECTURES.armv7),
'and what happens if it carries the other one');
});
test('the reason matches what node-patches actually builds', () => {
// The single thing that would make the note above a story rather than a fact.
const wf = path.join(repoRoot, '.tools/node-patches/.github/workflows/release-all.yml');
if (!fs.existsSync(wf)) {
console.log(' -- .tools/node-patches not cloned; skipping the cross-check');
return;
}
const src = fs.readFileSync(wf, 'utf8');
assert.ok(/node-armhf[\s\S]{0,200}?VFPv3-D16/.test(src),
'armhf is the VFPv3-D16 Debian baseline');
assert.ok(/node-armv7[\s\S]{0,200}?NEON/.test(src),
'armv7 is the NEON-tuned build');
});
test('i386 is recorded as not-a-snap, with the reason', () => {
assert.ok(NOT_SNAP_ARCHITECTURES.i386);
assert.ok(/core24/i.test(NOT_SNAP_ARCHITECTURES.i386));
assert.strictEqual(snapArchOf('i386'), '', 'and it does not resolve');
});
test('the architecture list matches what snapcraft.yaml actually builds', () => {
// The single thing that would make this whole table a lie.
const yaml = read('snapcraft.yaml');
const buildFor = [...yaml.matchAll(/^\s*build-for:\s*(\S+)\s*$/gm)].map(m => m[1]).sort();
assert.deepStrictEqual([...new Set(buildFor)], [...snapArchitectures()].sort(),
'snapcraft.yaml and the table disagree about which architectures are built');
});
test('negative: junk is refused rather than passed through to snapcraft', () => {
// An unrecognised --arch reaching `snapcraft release` is a silent no-op, which
// is the worst outcome: it looks like it worked.
['', null, undefined, 'x86', 'sparc', 'ppc64', 'arm', 42, {}]
.forEach(bad => assert.strictEqual(snapArchOf(bad), '', String(bad)));
assert.strictEqual(bundleArchOf('sparc'), '');
});
test('either spelling and any case is accepted, whitespace trimmed', () => {
assert.strictEqual(snapArchOf(' PPC64LE '), 'ppc64el');
assert.strictEqual(snapArchOf('x64'), 'amd64');
assert.strictEqual(snapArchOf('aarch64'), 'arm64');
});
// ───────────────────────────────────── revisions are per architecture
test('the newest revision for THAT architecture wins', () => {
const rows = [
{ revision: 10, arch: 'amd64', version: '10.75' },
{ revision: 11, arch: 'arm64', version: '10.75' },
{ revision: 12, arch: 'amd64', version: '10.75' },
];
assert.strictEqual(pickRevision(rows, 'amd64', '10.75'), 12);
assert.strictEqual(pickRevision(rows, 'arm64', '10.75'), 11,
"arm64 must not be given amd64's higher revision");
});
test('the highest wins, not whichever row came first', () => {
const rows = [
{ revision: 30, arch: 'amd64', version: '10.75' },
{ revision: 9, arch: 'amd64', version: '10.75' },
];
assert.strictEqual(pickRevision(rows, 'amd64'), 30);
});
test('a version filter publishes what was built, not whatever was uploaded last', () => {
const rows = [
{ revision: 40, arch: 'amd64', version: '10.76' },
{ revision: 39, arch: 'amd64', version: '10.75' },
];
assert.strictEqual(pickRevision(rows, 'amd64', '10.75'), 39);
assert.strictEqual(pickRevision(rows, 'amd64'), 40, 'and without one, the newest');
});
test('the bundle spelling works here too', () => {
const rows = [{ revision: 7, arch: 'ppc64el', version: '10.75' }];
assert.strictEqual(pickRevision(rows, 'ppc64le'), 7,
'asking with the bundle name finds the store row');
});
test('negative: no revision for an architecture is null, not a wrong one', () => {
const rows = [{ revision: 5, arch: 'amd64', version: '10.75' }];
assert.strictEqual(pickRevision(rows, 'riscv64'), null);
assert.strictEqual(pickRevision(rows, 'armv7'), null, 'and an unknown arch is null');
assert.strictEqual(pickRevision(rows, 'amd64', '9.99'), null, 'wrong version, no guess');
assert.strictEqual(pickRevision(null, 'amd64'), null);
assert.strictEqual(pickRevision([], 'amd64'), null);
});
// ─────────────────────────────────────────── parsing what the store prints
test('snapcraft list-revisions output is parsed', () => {
const out = [
'Rev. Uploaded Arch Version Channels',
'42 2026-08-09T04:00:00Z amd64 10.75 stable*',
'41 2026-08-09T03:00:00Z ppc64el 10.75 -',
'40 2026-08-08T03:00:00Z armhf 10.74 ',
].join('\n');
const rows = parseRevisions(out);
assert.strictEqual(rows.length, 3, 'the header is skipped');
assert.deepStrictEqual(rows[0], {
revision: 42, uploaded: '2026-08-09T04:00:00Z', arch: 'amd64',
version: '10.75', channels: 'stable*',
});
// A revision with no channels still parses: the columns are taken from the
// left, not counted from the right.
assert.strictEqual(rows[2].arch, 'armhf');
assert.strictEqual(rows[2].version, '10.74');
assert.strictEqual(pickRevision(rows, 'ppc64le'), 41);
});
test('negative: junk output yields no rows rather than nonsense', () => {
assert.deepStrictEqual(parseRevisions(''), []);
assert.deepStrictEqual(parseRevisions(null), []);
assert.deepStrictEqual(parseRevisions('error: not logged in'), []);
assert.deepStrictEqual(parseRevisions('Rev. Uploaded Arch Version Channels'), []);
});
// ─────────────────────────────────────────────────────────── the script
test('the release script reads the table instead of copying it', () => {
const sh = read('releases/snap-release-all-channels.sh');
assert.ok(/models\/lib\/snapArchitectures/.test(sh));
// A second copy of the arch list in bash is how the two would drift.
assert.ok(!/ppc64el\s+riscv64\s+s390x/.test(sh.replace(/#.*$/gm, '')),
'no hardcoded architecture list outside the comments');
});
test('it releases to every channel in ONE call', () => {
const sh = read('releases/snap-release-all-channels.sh');
assert.ok(/snapcraft release "\$snap" "\$revision" "\$channels"/.test(sh),
'a revision reaches all four channels or none');
});
test('one architecture failing does not stop the rest', () => {
const sh = read('releases/snap-release-all-channels.sh');
assert.ok(/failed=\$\(\(failed \+ 1\)\)/.test(sh));
assert.ok(/RELEASE FAILED/.test(sh));
});
test('it says what it is NOT publishing, every run', () => {
const sh = read('releases/snap-release-all-channels.sh');
assert.ok(/NOT_SNAP_ARCHITECTURES/.test(sh),
'"it is missing" and "it cannot be there" look identical without this');
});
test('the superseded three-channel helper is not what a release calls', () => {
const old = read('releases/snap-store-release-revision-to-channels.sh');
assert.ok(/edge,beta,candidate/.test(old), 'it is still there for a single manual release');
// ...but the new one is what covers everything.
const sh = read('releases/snap-release-all-channels.sh');
assert.ok(/stable/.test(sh));
});
console.log(`\n${passed} tests passed`);
+135
View File
@@ -0,0 +1,135 @@
'use strict';
// A snap stage-package must be a package that REALLY EXISTS on every
// architecture the snap is built for - not one that merely resolves on most of
// them.
//
// Ubuntu 24.04's 64-bit time_t transition renamed a set of libraries to a `t64`
// suffix. On the 64-bit architectures the renamed package keeps the old name
// alive:
//
// Package: libcurl4t64
// Architecture: amd64
// Provides: libcurl4 (= 8.5.0-2ubuntu10)
//
// but on **armhf**, where the ABI genuinely changed, there is no Provides line
// at all. So the old name resolves everywhere except armhf, and the snap fails
// on exactly one architecture, before it compiles anything:
//
// Stage package not found in part 'mongodb': libcurl4.
// Build failed
//
// That is a Launchpad "Stopped" build with no artifact, which looks like the
// build farm cancelling a job - so it reads as flakiness and gets retried. It
// was retried three times in v10.77 while s390x, ppc64el and riscv64 all built
// on attempt 1.
//
// It had already happened once, to libssl3 and libgoogle-perftools4 in v10.71.
// They were fixed; libcurl4 was the same transition and was left behind. This
// test is here so the third one is caught by a test run instead of by a release.
//
// Re-check the list against the archive with:
// curl -fsSL http://ports.ubuntu.com/ubuntu-ports/dists/noble/main/binary-armhf/Packages.gz \
// | gunzip -c | grep -E '^Package: ' | sed 's/^Package: //' | sort -u
// (and the same for universe, and archive.ubuntu.com for amd64)
//
// Run: node tests/snapStagePackages.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const yaml = fs.readFileSync(path.join(repoRoot, 'snapcraft.yaml'), 'utf8');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Every package named under a `stage-packages:` block.
function stagePackages() {
const out = [];
let inBlock = false;
yaml.split('\n').forEach(line => {
if (/^\s*stage-packages:\s*$/.test(line)) { inBlock = true; return; }
if (!inBlock) return;
const m = line.match(/^\s*-\s+([a-z0-9][a-z0-9.+-]*)\s*$/);
if (m) { out.push(m[1]); return; }
if (line.trim() && !line.trim().startsWith('#')) inBlock = false;
});
return out;
}
// The noble t64 renames that bite here: OLD NAME -> what noble actually ships.
// Each of these exists ONLY as the t64 package; the old name is a Provides on
// the 64-bit architectures and nothing at all on armhf. Verified against the
// noble main+universe indices for armhf and arm64.
const T64_RENAMED = {
'libssl3': 'libssl3t64',
'libcurl4': 'libcurl4t64',
'libcurl3-gnutls': 'libcurl3t64-gnutls',
'libgoogle-perftools4': 'libgoogle-perftools4t64',
'libboost-filesystem1.83.0': null, // NOT renamed - real on armhf
};
test('snapcraft.yaml declares stage-packages at all', () => {
const pkgs = stagePackages();
assert.ok(pkgs.length >= 10,
`expected the mongodb part's package list, found ${pkgs.length}`);
});
test('THE BUG: no stage-package is a pre-t64 name armhf cannot resolve', () => {
const pkgs = stagePackages();
const broken = pkgs.filter(p => T64_RENAMED[p]);
assert.deepStrictEqual(broken, [],
'these resolve on the 64-bit arches through a Provides and fail on armhf; ' +
'use ' + broken.map(p => `${p} -> ${T64_RENAMED[p]}`).join(', '));
});
test('libcurl is asked for by the name noble actually publishes', () => {
const pkgs = stagePackages();
assert.ok(pkgs.includes('libcurl4t64'),
'the mongodb part still needs libcurl');
assert.ok(!pkgs.includes('libcurl4'),
'libcurl4 is not a real package on any noble architecture');
});
test('the two that were fixed in v10.71 have not regressed', () => {
const pkgs = stagePackages();
assert.ok(pkgs.includes('libssl3t64'));
assert.ok(pkgs.includes('libgoogle-perftools4t64'));
assert.ok(!pkgs.includes('libssl3'));
assert.ok(!pkgs.includes('libgoogle-perftools4'));
});
test('NEGATIVE: a name that was never renamed is left alone', () => {
// The rule is not "add t64 to everything". libboost-filesystem1.83.0,
// zlib1g, libstemmer0d and the rest are real packages on armhf under their
// plain names, and a t64 suffix on them would not exist.
const pkgs = stagePackages();
['zlib1g', 'libstemmer0d', 'libsnappy1v5', 'libpcre2-8-0']
.forEach(p => assert.ok(pkgs.includes(p), `${p} must keep its plain name`));
pkgs.forEach(p => {
if (p.endsWith('t64')) {
assert.ok(Object.values(T64_RENAMED).includes(p),
`${p} carries a t64 suffix that is not one of the known renames - ` +
'check it exists before trusting it');
}
});
});
test('the reason is written down where the next person will read it', () => {
// A bare rename is a mystery in six months; this one has now cost two
// releases, and the comment is what stops it costing a third.
const block = yaml.slice(0, yaml.indexOf('- libcurl4t64'));
const near = block.slice(-2500);
assert.ok(/Provides/.test(near), 'the Provides mechanism is explained');
assert.ok(/armhf/.test(near), 'and which architecture it breaks');
assert.ok(/Stage package not found/.test(near),
'and the error it produces, so a search for it lands here');
});
console.log(`\n${passed} tests passed`);