mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149944726e | ||
|
|
bf0b757862 | ||
|
|
1c7de782f0 | ||
|
|
a820b056b2 | ||
|
|
6dce9dda79 | ||
|
|
47c81cd478 | ||
|
|
bc426ba7c2 | ||
|
|
a0f445c723 | ||
|
|
ce69db8c5f | ||
|
|
5b31695343 | ||
|
|
4a8fbd954e | ||
|
|
04fb6e3381 | ||
|
|
5957fa0ad8 | ||
|
|
97e57e531b | ||
|
|
fa296d0c80 | ||
|
|
040c18bc23 | ||
|
|
7241570491 | ||
|
|
979ad3a5cd | ||
|
|
3c9291338c | ||
|
|
a5d8a1fd97 | ||
|
|
ab29f74f6a | ||
|
|
48d8dc9de8 | ||
|
|
983aba8488 | ||
|
|
988e2f0794 | ||
|
|
90a41dbb22 | ||
|
|
8da91adaa7 | ||
|
|
80830cd43e | ||
|
|
f2edde0651 | ||
|
|
ada480c93a | ||
|
|
114d6c0599 | ||
|
|
fc0ce5137b | ||
|
|
c3c95cefb9 | ||
|
|
aea9811a76 | ||
|
|
5a1227da6c | ||
|
|
8a5ca2c2de | ||
|
|
30e8e38f6c | ||
|
|
2cc0d42ddf | ||
|
|
d577f07700 | ||
|
|
839d3a820c | ||
|
|
a4a9c03b1f | ||
|
|
156121c4bc | ||
|
|
2c4e81ae34 | ||
|
|
b37adda8a4 | ||
|
|
a8fa6cfa3f | ||
|
|
35daaaa279 | ||
|
|
08baf7fd1b | ||
|
|
0a9eb0ef4f | ||
|
|
b2ef10d9d1 | ||
|
|
ad62101e10 | ||
|
|
a1c500e33b | ||
|
|
454ec04b97 | ||
|
|
f4e5843016 | ||
|
|
0dbcfc44e0 | ||
|
|
0612630836 | ||
|
|
eab75eab70 | ||
|
|
aa8f591839 | ||
|
|
e16ffbc12b | ||
|
|
d911d71e53 | ||
|
|
79b10a1b43 | ||
|
|
8c2565e996 | ||
|
|
6def3a14dd | ||
|
|
0409b348af | ||
|
|
466bf21a57 | ||
|
|
c2ded4a599 | ||
|
|
9dbdcbefc0 | ||
|
|
74223ce69f | ||
|
|
f3a12533d7 | ||
|
|
0369b5042c | ||
|
|
70a3ac84c3 | ||
|
|
6234956437 | ||
|
|
96daaac3db | ||
|
|
5548029088 | ||
|
|
e7e7a700c6 | ||
|
|
5b83e8e256 | ||
|
|
ebd8a2d1d9 | ||
|
|
40fff29d08 | ||
|
|
909a5d9d5b | ||
|
|
e5d3935bc2 | ||
|
|
4319fc72db | ||
|
|
50083104c7 | ||
|
|
8b2e0038df | ||
|
|
244375ce13 | ||
|
|
c687daadf4 | ||
|
|
0b7aed89bd | ||
|
|
a50b483ff9 | ||
|
|
de8814a7cd | ||
|
|
a95451c67b | ||
|
|
f049503ee5 | ||
|
|
24927c3a7a | ||
|
|
75d1e74715 | ||
|
|
f138b3124f | ||
|
|
fdc85961a2 | ||
|
|
b55cbda20d | ||
|
|
94940ada13 | ||
|
|
c6b0af16eb | ||
|
|
ee16a41a99 | ||
|
|
3c7926662c | ||
|
|
9773312176 | ||
|
|
6cada892d9 | ||
|
|
f053619d80 | ||
|
|
4063cd20ee | ||
|
|
a74aee2e45 | ||
|
|
e30eb57e1a | ||
|
|
0878de010e | ||
|
|
65984b912e | ||
|
|
a7f840be48 | ||
|
|
2f29b56f36 | ||
|
|
8ebeb85832 | ||
|
|
459ea80d04 | ||
|
|
7727e51296 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Project-scoped Codex configuration.
|
||||
#
|
||||
# Claude has no non-default tool permissions or MCP servers configured for this
|
||||
# repository, so there are no settings to translate here. Project instructions
|
||||
# live in AGENTS.md and the nested AGENTS.md files.
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: find
|
||||
env:
|
||||
@@ -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
|
||||
@@ -130,7 +160,7 @@ jobs:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
# An AppImage only WRAPS an existing release bundle
|
||||
@@ -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
|
||||
@@ -298,7 +345,7 @@ jobs:
|
||||
fi
|
||||
echo "WeKan answered on port 8080."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: appimage-${{ matrix.appimage_arch }}
|
||||
@@ -311,7 +358,7 @@ jobs:
|
||||
if: ${{ always() && inputs.publish != false }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: appimage-*
|
||||
path: assets
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -89,14 +98,18 @@ jobs:
|
||||
tag: ${{ steps.find.outputs.tag }}
|
||||
version: ${{ steps.find.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- id: find
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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
|
||||
@@ -133,7 +146,7 @@ jobs:
|
||||
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
- name: Install flatpak
|
||||
@@ -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
|
||||
@@ -273,14 +287,14 @@ jobs:
|
||||
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
|
||||
ls -lh dist
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-${{ matrix.flatpak_arch }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
with:
|
||||
name: flatpak-repo-${{ matrix.flatpak_arch }}
|
||||
@@ -293,7 +307,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: flatpak-[0-9a-z]*
|
||||
path: assets
|
||||
@@ -318,11 +332,24 @@ 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@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
pattern: flatpak-repo-*
|
||||
@@ -395,7 +422,7 @@ jobs:
|
||||
TXT
|
||||
du -sh flatpak-repo
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ inputs.publish_repo != false }}
|
||||
with:
|
||||
name: flatpak-repository
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
manual: ${{ steps.plan.outputs.manual }}
|
||||
total: ${{ steps.plan.outputs.total }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: plan
|
||||
env:
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
# Before the download, because actions/checkout cleans the workspace.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: ${{ steps.want.outputs.build == 'yes' }}
|
||||
|
||||
# THE DIFFERENCE from release-all.yml, and the whole point of this
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
V: ${{ needs.plan.outputs.version }}
|
||||
|
||||
@@ -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
|
||||
@@ -659,7 +669,7 @@ jobs:
|
||||
done
|
||||
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: provenance-amd64
|
||||
path: provenance/
|
||||
@@ -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
|
||||
@@ -864,7 +875,7 @@ jobs:
|
||||
done
|
||||
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
with:
|
||||
name: provenance-arm64
|
||||
@@ -921,7 +932,7 @@ jobs:
|
||||
# 404'd on wekan-<version>-amd64.zip - v10.63 and v10.64 both. Checking out
|
||||
# first, then downloading on top, keeps the bundles. (The checkout is here
|
||||
# for the provenance script under releases/.)
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download all bundles
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -931,7 +942,7 @@ jobs:
|
||||
|
||||
- name: Collect where the binaries came from
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: provenance-*
|
||||
path: provenance
|
||||
@@ -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
|
||||
@@ -2290,7 +2305,7 @@ jobs:
|
||||
# checkout - the job would stop at "No such file or directory". It goes
|
||||
# first because actions/checkout cleans the workspace, which would remove
|
||||
# the bundle the next step downloads.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -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.
|
||||
@@ -2490,7 +2505,7 @@ jobs:
|
||||
mkdir -p provenance
|
||||
[ -f provenance.tsv ] && mv provenance.tsv "provenance/${{ matrix.arch }}.tsv" || true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
with:
|
||||
name: provenance-${{ matrix.arch }}
|
||||
@@ -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} \
|
||||
@@ -3337,7 +3366,14 @@ jobs:
|
||||
# clear message instead of GitHub's silent 6h cancel. fail-fast: false so one arch
|
||||
# never cancels the others - each publishes the moment it finishes.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 180
|
||||
# 180 minutes was one attempt on the slow arches, not three. In v10.71 the
|
||||
# riscv64 leg spent 2h24m on attempt 1 - almost all of it queueing for a
|
||||
# riscv64 builder - failed it, and was cancelled by this timeout 35 minutes
|
||||
# into attempt 2, which is the "The operation was canceled." in that job and
|
||||
# not a Launchpad problem at all. The retry loop is only worth having if the
|
||||
# job outlives the retries, so allow for two full slow-arch attempts. 350 is
|
||||
# under GitHub's 360-minute per-job ceiling.
|
||||
timeout-minutes: 350
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -3477,7 +3513,15 @@ jobs:
|
||||
# when snapcraft's exit code is non-zero, which is how v10.48 fetched it
|
||||
# and threw it away.
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
echo "::group::Launchpad build log: $log (last 150 lines)"
|
||||
echo "::group::Launchpad build log: $log (what failed, then the last 150 lines)"
|
||||
# The tail alone is not where the cause is. snapcraft's own error is
|
||||
# followed by lpbuildd's Python traceback, the proxy-token revocation
|
||||
# and the process scan, so on v10.71 the 150 lines held the
|
||||
# IsADirectoryError but NOT the mongodb part's build output that
|
||||
# would have said what `bin` actually was. Print the lines around
|
||||
# every failure marker first, and keep the tail after it.
|
||||
grep -n -E -B 5 -A 20 'Build failed|failed with code|Detailed information|Stage package not found|Failed to run snapcraft|error:' "$log" | tail -n 250 || true
|
||||
echo "--- last 150 lines ---"
|
||||
tail -n 150 "$log"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -3506,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)
|
||||
@@ -3523,14 +3580,61 @@ jobs:
|
||||
# (The large-history push that also broke these builds is fixed
|
||||
# separately, by flattening the repo before the push.)
|
||||
echo "::error::If the build ended as 'Stopped' with no artifact: the Launchpad project https://launchpad.net/xet7-craft-remote-build should be MIT / X/Expat (it is set now) - re-set it only if it shows 'None specified'. Otherwise Launchpad cancelled the build as it ran (often an OOM in the Meteor npm install, or a transient build-farm reset - the printed build log ends mid-step); re-run, and if it persists on this arch open a Launchpad question. Builds: https://launchpad.net/~xet7/+snaps" >&2
|
||||
# The commonest cause after a plain build error is the credential: Launchpad
|
||||
# answers 401/403 for an expired or wrong LP_CREDENTIALS, which reads as an
|
||||
# ordinary build failure unless the log says so.
|
||||
if grep -qiE 'unauthoriz|401|403|invalid credentials|not logged in' snapcraft-wekan-*.txt 2>/dev/null; then
|
||||
echo "::error::LP_CREDENTIALS may not work: Launchpad answered unauthorized. Re-create it (snapcraft remote-build once locally) and set: base64 -w0 ~/.local/share/snapcraft/launchpad-credentials" >&2
|
||||
# A wrong or expired LP_CREDENTIALS makes Launchpad answer 401/403, which
|
||||
# reads as an ordinary build failure unless the log is checked. But the
|
||||
# pattern must be SPECIFIC: a bare `401`/`403` matches any three digits in
|
||||
# the build log - a package size, a hash, "attempt 4/6" - so the old
|
||||
# `401|403` fired on almost every large log and told the maintainer to
|
||||
# re-create working credentials on every Stopped build. A build that
|
||||
# reached "Building:" authenticated fine; the giveaway of a real auth
|
||||
# failure is those codes in an HTTP-error context, or an explicit phrase.
|
||||
if grep -qiE 'HTTP Error 40[13]|(^|[^0-9])40[13] (Unauthorized|Forbidden)|\bunauthoriz(ed|ation)\b|invalid credentials|bad credentials|not logged in|need to (log|sign) in|macaroon' snapcraft-wekan-*.txt 2>/dev/null; then
|
||||
echo "::error::LP_CREDENTIALS may not work: the Launchpad build log shows an authorization failure. Re-create it (snapcraft remote-build once locally) and set: base64 -w0 ~/.local/share/snapcraft/launchpad-credentials" >&2
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Collect the FULL build logs for ${{ matrix.arch }}
|
||||
# An excerpt in the job log answers "what failed"; it does not answer
|
||||
# "what did the mongodb part print an hour earlier", which is what the
|
||||
# v10.71 staging failure needed and nobody could get afterwards - the
|
||||
# Launchpad build log is deleted with the temporary snap recipe, so once
|
||||
# the job is over the only copy is the one kept here. always(), not
|
||||
# failure(): a build that succeeded on attempt 2 hides why attempt 1 did
|
||||
# not, and these are a few hundred KB of text.
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p snap-logs
|
||||
# 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 }}
|
||||
path: snap-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
@@ -3958,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} .
|
||||
@@ -4045,9 +4150,9 @@ jobs:
|
||||
if: ${{ always() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: provenance-*
|
||||
|
||||
+8
-23
@@ -67,7 +67,14 @@ test-results/
|
||||
# Claude AI
|
||||
.claude/
|
||||
|
||||
# Tools like mongosh etc so that they work inside Flatpak sandbox
|
||||
# Companion repositories and tools, cloned INSIDE this checkout so they work in a
|
||||
# Flatpak sandbox that shares only this directory. One ignored directory for all
|
||||
# of them - it replaced an entry per clone (/FerretDB/, /node/, /mongo-tools/,
|
||||
# /mongosh/, /sandstorm/, /projects/, and the WeKan variants), which had to be
|
||||
# extended by hand for every new one. build.sh's ensure_tool_repo clones what a
|
||||
# test run needs into here: .tools/FerretDB, .tools/node-patches,
|
||||
# .tools/mongo-tools-patches. Nothing in here is WeKan's source, and Meteor is
|
||||
# told the same by /.tools/ in .meteorignore.
|
||||
.tools/
|
||||
|
||||
# Meteor custom local directory (METEOR_LOCAL_DIR)
|
||||
@@ -75,28 +82,6 @@ _build-local-test
|
||||
*/build-assets-local-test
|
||||
*/build-chunks-local-test
|
||||
|
||||
# Local-only working copies: other wekan repos cloned under this repo for editing,
|
||||
# and build inputs. These are separate git repos / large machine-specific dirs;
|
||||
# never commit them.
|
||||
# - projects/ : unarchived meteor-spk fork (Node/Mongo/FerretDB binaries)
|
||||
# - sandstorm/ : clone of sandstorm-io/sandstorm (platform source, for reference)
|
||||
# - FerretDB/ : clone of wekan/FerretDB
|
||||
# - mongo-tools/ : clone of wekan/mongo-tools
|
||||
# - mongosh/ : clone of wekan/mongosh
|
||||
# - node/ : clone of the Node.js 24.x fork WeKan builds its runtime from
|
||||
# - TSC/ : clone of Secretchronicles/TSC
|
||||
# - secretchronicles.github.io/ : clone of the TSC website of the same name
|
||||
# - gitea/ : clone of wekan/gitea, the fork of go-gitea/gitea
|
||||
/projects/
|
||||
/sandstorm/
|
||||
/FerretDB/
|
||||
/mongo-tools/
|
||||
/mongosh/
|
||||
/node/
|
||||
/TSC/
|
||||
/secretchronicles.github.io/
|
||||
/gitea/
|
||||
|
||||
# Test and build logs, when they land INSIDE the repo. build.sh writes to
|
||||
# ../log/<datetime>/ by preference - one level up, so a test run does not show
|
||||
# up in git status - but the parent of the repo is not writable in a Flatpak
|
||||
|
||||
+4
-4
@@ -6,9 +6,9 @@
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript@0.19.0-beta351.0
|
||||
ecmascript@0.19.0
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.2.0-beta351.0
|
||||
rspack@1.2.0
|
||||
# Declared explicitly because server/modernBrowsers.js imports it, to say that
|
||||
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
|
||||
modern-browsers@0.2.3
|
||||
@@ -19,10 +19,10 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.5.0-beta351.0
|
||||
mongo@2.5.0
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.1-beta351.0
|
||||
accounts-password@3.3.1
|
||||
accounts-express@1.0.0
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5.1-beta.0
|
||||
METEOR@3.5.1
|
||||
+14
-14
@@ -1,14 +1,14 @@
|
||||
accounts-base@3.3.0
|
||||
accounts-express@1.0.0
|
||||
accounts-oauth@1.4.7
|
||||
accounts-password@3.3.1-beta351.0
|
||||
accounts-password@3.3.1
|
||||
aldeed:collection2@4.2.1
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.15.0-beta351.0
|
||||
babel-compiler@7.15.0
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.14
|
||||
binary-heap@1.0.13
|
||||
@@ -24,13 +24,13 @@ communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.4.0-beta351.0
|
||||
ddp-client@3.4.0
|
||||
ddp-common@1.4.5
|
||||
ddp-rate-limiter@1.3.0
|
||||
ddp-server@3.4.0-beta351.0
|
||||
ddp-server@3.4.0
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.19.0-beta351.0
|
||||
ecmascript@0.19.0
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
@@ -44,7 +44,7 @@ hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.0
|
||||
inter-process-messaging@0.1.3-beta351.0
|
||||
inter-process-messaging@0.1.3
|
||||
jquery@3.0.2
|
||||
lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
@@ -56,16 +56,16 @@ meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2
|
||||
minifier-js@3.3.0-beta351.0
|
||||
minifier-js@3.3.0
|
||||
minimongo@2.2.0
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.5.0-beta351.0
|
||||
mongo@2.5.0
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.2-beta351.0
|
||||
npm-mongo@6.16.2
|
||||
oauth@3.0.4
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
@@ -87,12 +87,12 @@ reload@1.3.2
|
||||
retry@1.1.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.2.0-beta351.0
|
||||
rspack@1.2.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.3
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.1-beta351.0
|
||||
socket-stream-client@0.7.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
@@ -100,11 +100,11 @@ templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.2.0-beta351.0
|
||||
tools-core@1.2.0
|
||||
tracker@1.3.4
|
||||
typescript@5.11.0-beta351.0
|
||||
typescript@5.11.0
|
||||
url@1.3.6
|
||||
webapp@2.3.0-beta351.0
|
||||
webapp@2.3.0
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
@@ -19,71 +19,6 @@ npm-packages/
|
||||
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
|
||||
/.tools/
|
||||
|
||||
# The FerretDB v1 fork checkout: a complete Go project + its docs, 9,148 more
|
||||
# directories. Nothing in WeKan imports from it - the FerretDB binary is
|
||||
# downloaded from its GitHub releases at build time - so Meteor has no reason to
|
||||
# watch or scan it. Both are already in .gitignore, but the watcher only honours
|
||||
# .meteorignore.
|
||||
/FerretDB/
|
||||
|
||||
# ── The other checkouts that have accumulated beside the app ─────────────────
|
||||
#
|
||||
# Everything below is the SAME problem as /.tools/ and /FerretDB/ above, and it
|
||||
# is why `meteor build` ran out of memory: Meteor scans the app directory, and
|
||||
# these are in .gitignore only, which the scanner does not read.
|
||||
#
|
||||
# The count is what makes it fatal. Meteor builds one ignore matcher per
|
||||
# directory it descends into, each carrying the whole accumulated pattern list,
|
||||
# and a heap snapshot taken at the moment the build died showed 945 of those
|
||||
# matchers holding 14.3 million rule objects - 980 MB of IgnoreRule plus 667 MB
|
||||
# of sliced strings, 308 MB of concatenated strings and 274 MB in 945 copies of
|
||||
# one 296 KB pattern list: about 2.5 GB of the 4.1 GB heap, and still only 945
|
||||
# directories in. There are 6,867 directories under this one, of which 5,860 are
|
||||
# in the trees named here; WeKan's own code is the remaining ~1,000. So the cost
|
||||
# was very nearly SEVEN TIMES what the snapshot caught, which is why raising
|
||||
# --max-old-space-size never helped - the ceiling was not the variable.
|
||||
#
|
||||
# Excluding them takes the scan back to WeKan itself.
|
||||
|
||||
# The Node.js fork checkout (github.com/wekan/node): 4,132 directories, 45,527
|
||||
# files, 2.3 GB - by far the largest of these. WeKan uses its RELEASE BINARIES,
|
||||
# downloaded per architecture at build time; the source is here to build them,
|
||||
# and nothing in the app imports it.
|
||||
/node/
|
||||
|
||||
# The mongo-tools checkout: another Go project, 738 directories. Same story -
|
||||
# WeKan ships the built binaries, not this source.
|
||||
/mongo-tools/
|
||||
|
||||
# TSC (the game) and its website, cloned here for the release work. 472 and 512
|
||||
# directories, 810 MB together, and not WeKan at all.
|
||||
/TSC/
|
||||
/secretchronicles.github.io/
|
||||
|
||||
# The Gitea fork (wekan/gitea, forked from go-gitea/gitea): a whole Go + web
|
||||
# application, 1,401 directories and 384 MB - about a fifth again of everything
|
||||
# Meteor legitimately walks here. Nothing in WeKan imports it.
|
||||
/gitea/
|
||||
|
||||
# Two more WeKan checkouts, 296 directories each. These are worse than the rest:
|
||||
# they contain client/, server/ and models/, so they are not merely scanned -
|
||||
# Meteor loads server/ and client/ EAGERLY, and it would be pulling a second and
|
||||
# third copy of the whole app into this build.
|
||||
/wekan-gantt-gpl/
|
||||
/wekan-ondra/
|
||||
|
||||
# The node-patches checkout (wekan/node-patches): the PATCHES to upstream
|
||||
# Node.js that WeKan's third Node.js source is built from - .patch files, their
|
||||
# checksums and their docs. Nothing in WeKan imports it; it is here because it is
|
||||
# worked on beside WeKan, and it is gitignored for the same reason.
|
||||
/node-patches/
|
||||
|
||||
# The mongo-tools-patches checkout (wekan/mongo-tools-patches): the PATCHES to
|
||||
# the upstream MongoDB Database Tools the bundled bsondump/mongodump/... are
|
||||
# built from - .patch files, their checksums, their docs and the build. Nothing
|
||||
# in WeKan imports it, and it replaces the /mongo-tools/ fork excluded above.
|
||||
/mongo-tools-patches/
|
||||
|
||||
# DO NOT ADD /_build/ OR /_build-local-test/ HERE. They look like build output
|
||||
# that should be ignored - they are gitignored, and they are the first two
|
||||
# entries of the 296 KB pattern list above - and excluding them breaks the
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# AGENTS.md — instructions Codex reads first
|
||||
|
||||
Codex reads this file at the repo root before doing work here. Follow it.
|
||||
|
||||
## First: are you the maintainer or a contributor?
|
||||
|
||||
Check the current git identity before committing or releasing:
|
||||
|
||||
```
|
||||
git config user.name && git config user.email
|
||||
```
|
||||
|
||||
- **Maintainer mode** — ONLY when the identity is exactly
|
||||
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
|
||||
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
|
||||
with no AI trailer and no pull request, and the **publishing / release steps** below
|
||||
are available.
|
||||
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
|
||||
the branch and do **not** run any release/publishing step. Make changes on a branch
|
||||
and open a **pull request** for the maintainer to review. The "commit as Lauri
|
||||
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
|
||||
and do not apply to you**.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
|
||||
When asked to fix an open issue (one issue at a time):
|
||||
|
||||
1. **Read the whole issue** — the description AND all comments (e.g.
|
||||
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
|
||||
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
|
||||
AND FerretDB source code (read the actual code, not just changelogs). It may have
|
||||
been fixed already.
|
||||
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
|
||||
the dependencies involved, checking where the newest MAINTAINED dependency lives and
|
||||
reading its source and issues:
|
||||
- Meteor 3 docs — https://docs.meteor.com/
|
||||
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
|
||||
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
|
||||
- Meteor forums — https://forums.meteor.com
|
||||
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
|
||||
- Meteor Community Packages — https://github.com/meteor-community-packages
|
||||
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
|
||||
- npm — https://www.npmjs.com
|
||||
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
|
||||
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
|
||||
build+test instructions in `docs/Security/Sandboxes/vscode/README.md`).
|
||||
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
|
||||
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
|
||||
|
||||
```
|
||||
Thanks to (GitHub nickname of the issue creator) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
Fix from source and test — do not guess. If the environment cannot run the relevant
|
||||
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
|
||||
from source and say clearly what was and was not verified.
|
||||
|
||||
## Translations (Transifex ↔ direct LLM fill, no external service)
|
||||
|
||||
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
|
||||
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
|
||||
the human translations. **The policy is: never overwrite a human translation with a
|
||||
filled (or English) one, but always take the newest translations from Transifex.**
|
||||
|
||||
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
|
||||
(which fills every string that is UNtranslated on Transifex with the English source)
|
||||
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
|
||||
for every language file and every key:
|
||||
- Transifex has a real translation (pulled value differs from English) → **keep it**
|
||||
(the newest human translation always wins);
|
||||
- the pull reverted it to English but a human translation is committed in git →
|
||||
**restore the committed translation** (a pull never reverts a human translation,
|
||||
even in files that also received real new Transifex translations);
|
||||
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
|
||||
the English source as a placeholder. **This is the only case a non-human value is
|
||||
used.** A separate fill step may fill *only* these English placeholders, so a filled
|
||||
string can never overwrite a human translation.
|
||||
- Restored languages are pushed back to Transifex so they stop reverting.
|
||||
|
||||
### Filling the remaining untranslated strings — directly, no translation service
|
||||
|
||||
The strings still equal to the English source after the merge are the ones untranslated
|
||||
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
|
||||
assistant (an LLM) writes the translation itself, using that language's **existing
|
||||
translations** and general **kanban terminology** for the language as the reference.
|
||||
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
|
||||
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
|
||||
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
|
||||
|
||||
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
|
||||
strings still needing translation (English + `en-*` variants are skipped: English by
|
||||
design). Also printed at the end of `pull-translations.sh`.
|
||||
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
|
||||
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
|
||||
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
|
||||
merge the translations back. It writes **only** into placeholder keys, so it can
|
||||
**never** overwrite a human translation, and a value still equal to English/empty is
|
||||
ignored. Key order and 2-space indent are preserved.
|
||||
|
||||
**Both directions are safe, and it is verified:**
|
||||
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
|
||||
that the pull-merge keeps/restores human translations and that a fill only touches
|
||||
placeholders. Filled strings stay **local** — they are **NOT** pushed to Transifex (only
|
||||
the merge-restored human languages are pushed), so a filled string can never masquerade
|
||||
as human there. So: **only missing strings are ever filled, only when missing
|
||||
everywhere, human strings are always preferred and merged**, and nothing you fill is
|
||||
pushed to Transifex as if it were human.
|
||||
|
||||
## General practices (from ../log/v10/Claude.txt)
|
||||
|
||||
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
|
||||
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
|
||||
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
|
||||
do the opposite: work on a branch and open a pull request — see the top section.)
|
||||
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
|
||||
- `../w/wekan.fi` — the WeKan website.
|
||||
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
|
||||
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
|
||||
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
|
||||
use `wekan/wekan#NNNN`.
|
||||
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- During development, add entries under a new `# Upcoming WeKan ® release` section above
|
||||
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
|
||||
`package.json` or any other version reference — the release workflow bumps those.
|
||||
- **The file's shape, top to bottom** — keep it exactly as it is now:
|
||||
1. `# Platforms` — the line `Newest WeKan at these platforms:` and the Install /
|
||||
Upgrade / Docs / Mac ChangeLog bullets, then a `<details>` whose `<summary>` is
|
||||
`Version` holding "which WeKan version uses what". There is no `# Version`
|
||||
heading of its own.
|
||||
2. `# TODO Later` — a `<details>` whose `<summary>` is `Carried to a future
|
||||
release.` explaining the list, then one `<details>` per category (below).
|
||||
3. The releases, newest first, each `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release`.
|
||||
|
||||
Nothing else is an `#` heading. A `##`/`###` inside a release would break the
|
||||
version list, and a wrapped line that BEGINS with `#` (e.g. an issue number such
|
||||
as `#6514`) becomes a heading too — escape it `\#6514` or keep it off the line
|
||||
start.
|
||||
- **A release's order of subsections** — CRITICAL SECURITY ISSUES first, then new
|
||||
features, then the Admin Panel / UI reorganisation if there is one, then dependency
|
||||
updates (`Thanks to dependabot.`), then bug fixes, then developer-facing changes,
|
||||
documentation and translations. The section ends with the line `Thanks to above
|
||||
GitHub users for their contributions and translators for their translations.`
|
||||
- **Every entry is a `<details>` block, and they are what a release is made of.**
|
||||
The `<summary>` is the SHORT description of what was done and IS the link to the
|
||||
commit — the hash lives in the `href` and is never on the page — followed by the
|
||||
`Thanks to …` sentence. Clicking it reveals the long description:
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/<hash>">Short description of
|
||||
what was done</a>. Thanks to (issue creator's GitHub nickname) and xet7.</summary>
|
||||
|
||||
The long description: what was wrong, why, what it does now, what the test pins.
|
||||
Word-wrapped at 80 characters, ordinary markdown - links, `code`, emphasis.
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Exactly as above: `<details>` and `</details>` each on their own line, the whole
|
||||
`<summary>…</summary>` on ONE line, a blank line under it, the body, a blank line,
|
||||
the close — and a blank line between two blocks.
|
||||
- **The summary is one line at a glance** — aim for ≤ 110 characters — and PLAIN text:
|
||||
no `[text](url)`, no `**bold**`, no backticks inside `<summary>`, because a link
|
||||
cannot nest inside the `<a>` and the rest renders literally. It ends with a full
|
||||
stop, then `Thanks to …`. Every summary in a release links its own commit.
|
||||
- **A change with nothing more to say stays a plain bullet** —
|
||||
`- [Short description](https://github.com/wekan/wekan/commit/<hash>). Thanks to xet7.`
|
||||
— and a dependency batch keeps its `- **package 1.2.3 → 1.2.4** — one line on what
|
||||
it is` bullets, closing with `Thanks to dependabot.` A `<details>` whose body only
|
||||
repeats its summary is noise; use one when there IS a longer story to reveal, which
|
||||
is most fixes.
|
||||
- **`# TODO Later` blocks are the same shape with two differences:** the `<summary>` is
|
||||
the short category text (no `<a>`, because nothing was committed), and there is **no
|
||||
`Thanks to`** — nothing is done yet, so there is nobody to thank. The body lists the
|
||||
issues as `[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)`.
|
||||
- **The hash is never the link text.** `[f1c89548e](…)` shows a hash to a reader who
|
||||
cannot do anything with it; the link text says what changed. Same for `[merge
|
||||
commit](…)` and for a bare URL — see the next rule.
|
||||
- **Never show a long URL as visible text.** A link is always
|
||||
`[short text](url)` — an issue is `[#6524](…/issues/6524)`, an advisory is
|
||||
`[GHSA-xxxx](…)`, a security page is `[ZipBleed](https://wekan.fi/hall-of-fame/zipbleed/)`.
|
||||
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
|
||||
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
|
||||
subsection in the SAME release starts with a lowercase `and ` instead of repeating
|
||||
`This release ` — e.g.
|
||||
`This release fixes the following bugs:` … `and updates the following dependencies:` …
|
||||
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
|
||||
… then the closing `Thanks to above GitHub users …` line. A release with only one
|
||||
subsection just keeps its single `This release …:` header. Use `and adds the following
|
||||
new features:`, `and fixes the following bugs:`, `and updates the following
|
||||
dependencies:`, etc., matching the verb to the subsection.
|
||||
- **The Upcoming section opens with an `**In short:**` paragraph.** One paragraph,
|
||||
right under the `# Upcoming WeKan ® release` line and above the first
|
||||
`This release …:` header, saying what the whole release amounts to — the areas
|
||||
that changed and what changed about them, with the notable names in `**bold**`
|
||||
so it can be skimmed. It is a SUMMARY, not a list: it does not link commits, it
|
||||
does not repeat an entry's wording, and it ends by accounting for the rest in a
|
||||
clause (`Below that: dependency updates, nine bug fixes … and the usual
|
||||
documentation and translation work.`). Keep it current as entries are added —
|
||||
it is the first thing a reader sees, so a stale one misdescribes the release.
|
||||
A finished release keeps the paragraph it was written with.
|
||||
- **Under the summary comes the BINARIES TABLE: what each platform ships.** So the
|
||||
top of a release section is, in order, (1) the `**In short:**` paragraph and
|
||||
(2) this table, and only then the `This release …:` subsections. A WeKan bundle
|
||||
is not only WeKan — it carries a Node.js, a FerretDB and the MongoDB Database
|
||||
Tools that other projects publish, and WHICH source has a given CPU changes
|
||||
from release to release: nodejs.org builds some architectures,
|
||||
unofficial-builds others, and [wekan/node-patches](https://github.com/wekan/node-patches)
|
||||
the ones neither of them does. "Which Node.js is in the arm64 bundle of 10.69,
|
||||
and was it checked" must be answerable from the CHANGELOG, not from a build log
|
||||
that expires.
|
||||
|
||||
```
|
||||
| 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 | `a1b2…` |
|
||||
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.24.0/ferretdb-amd64) | v1.24.0 | `c3d4…` |
|
||||
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `e5f6…` |
|
||||
```
|
||||
|
||||
**GROUPED BY PLATFORM**: rows are sorted by platform and then by binary, so one
|
||||
platform's binaries stay together and the table is read a platform at a time.
|
||||
The URL is the LINK ON THE "From" CELL — never a bare URL as visible text, the
|
||||
same rule as everywhere else — and it is the exact file that was downloaded, not
|
||||
the project's front page. The SHA256 is the checksum the source published and the
|
||||
build verified, in backticks; a source that publishes none says *no checksum
|
||||
published*, which is not a failed check but a source worth fixing. Table rows
|
||||
carry links, so the 80-character wrap does not apply to them.
|
||||
|
||||
It is the same table `releases/provenance-table.sh` puts at the top of the GitHub
|
||||
release notes, from the `provenance.tsv` rows each build job records — so the two
|
||||
are filled from the same data and cannot disagree. **A platform that was NOT built
|
||||
has no rows**, which is how the table also answers "why is there no i386 bundle
|
||||
this time": no source published a Node.js for it (see
|
||||
`releases/resolve-node-source.sh`).
|
||||
- **Inside a subsection, entries are GROUPED BY AREA.** A release touches a handful
|
||||
of areas and repeating the area's name in every summary is the noise this
|
||||
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
|
||||
times and the part that differs starts halfway through the line. Instead the area
|
||||
is named ONCE, as a bold line with a short description of what the group covers,
|
||||
and every entry under it drops the prefix:
|
||||
|
||||
```
|
||||
and fixes the following bugs:
|
||||
|
||||
**The first header bar** - how it lays itself out, and what sits under it.
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
|
||||
…
|
||||
</details>
|
||||
|
||||
**All Boards** - the overview and its search.
|
||||
|
||||
<details>
|
||||
…
|
||||
```
|
||||
|
||||
The group line is `**Area** - short description.` on ONE line, wrapped at 80 like
|
||||
everything else, with a blank line under it. It is NOT a heading: a `##` inside a
|
||||
release breaks the version list (see above). Group labels are the areas of the
|
||||
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
|
||||
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
|
||||
label is reused across subsections when an area has both a feature and a fix.
|
||||
EVERY entry of a grouped subsection belongs to a group, including a group with
|
||||
one entry: a section that is half grouped and half loose reads as a mistake. A
|
||||
subsection with only one entry, and the dependency bullets, stay flat.
|
||||
- **CRITICAL security header — match the previous releases' wording.** A security release
|
||||
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
|
||||
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
|
||||
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
|
||||
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
|
||||
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
|
||||
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
|
||||
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
|
||||
subsection becomes `and …` per the rule above.
|
||||
- **`# TODO Later` section** — a triage backlog near the TOP of `CHANGELOG.md` (above the
|
||||
version sections), for open issues that were **investigated but not fixed here**, each
|
||||
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
|
||||
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
|
||||
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
|
||||
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
|
||||
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
|
||||
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
|
||||
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
|
||||
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
|
||||
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
|
||||
*Needs a maintainer decision on the intended contract*. Each category is a `<details>`
|
||||
whose `<summary>` is the SHORT category text and whose body lists the issues with their
|
||||
reasons — and with **no `Thanks to` line**, because nothing is done yet, so there is
|
||||
nobody to thank. Each entry is a normal issue link
|
||||
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
|
||||
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
|
||||
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
|
||||
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
|
||||
the releases; everything else uses the per-release commit-link bullets above.
|
||||
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
|
||||
🤖`; entries end `... by @xet7. Thanks to xet7.`
|
||||
- Word-wrap both CHANGELOGs at 80 chars, but never break a long link across lines (a
|
||||
`<summary>` line, or any other line carrying a link, may be longer). Continuation
|
||||
lines of a plain bullet are indented by two spaces.
|
||||
|
||||
### Commit message structure
|
||||
|
||||
```
|
||||
Do something.
|
||||
|
||||
Thanks to (original creator of issue) and xet7 !
|
||||
|
||||
Fixes #1234,
|
||||
Fixes #1235.
|
||||
```
|
||||
|
||||
### Making a release — no version number needed **[maintainer only]**
|
||||
|
||||
All publishing / release steps below are maintainer-only. Contributors never run them.
|
||||
|
||||
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
|
||||
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
|
||||
9.99 → 10.00) dated today, commits + pushes, and triggers
|
||||
`.github/workflows/release-all.yml` (its `bump` job bumps `package.json` and every
|
||||
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
|
||||
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
|
||||
is the only hand step.
|
||||
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
|
||||
version). It renames `## Upcoming FerretDB release` to the next version with the
|
||||
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
|
||||
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
|
||||
already-tagged version.
|
||||
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `../w/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **A Hall of Fame row has EIGHT cells**, in this order: CVE, Icon, Vulnerability
|
||||
name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities.
|
||||
One thing per cell — the name without its icons, the Font Awesome icons alone in
|
||||
Icon (still wrapped in `<h2>`), the reporter alone in "by", the `GoldStar.png`
|
||||
images alone in Stars, and the sentence about how the report arrived (coordinated
|
||||
disclosure, GitHub advisory, code scanning, found while reviewing …) in Process.
|
||||
In "by", the reporter's NICKNAME is the link —
|
||||
`<b><a href="https://github.com/nick">nick</a></b>`, never the name followed by a
|
||||
separate "(GitHub)" link. **Check the account exists before linking it** (a 404 or,
|
||||
worse, somebody else's profile is the failure here); a real name, a company or a
|
||||
tool such as GitHub CodeQL is not a GitHub user and stays plain text. Do not write
|
||||
a role note after a name — no `(fix)`, `(found)` or `(found and fix)`: this column
|
||||
is the credit, and the Process cell beside it already says how the report arrived.
|
||||
Stars is a column so it can be compared down the page; do not put a star, an icon
|
||||
or the process sentence back into another cell. A page-local `<style>` in
|
||||
`index.html` keeps a row's stars on ONE line from 900px up (they are a count) and
|
||||
sizes them at 24px, and keeps the icon and its red drop on one line at every width
|
||||
(they are one icon) — the table itself stays percentage-sized with no fixed or
|
||||
minimum width, so it fills the page at every browser width.
|
||||
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
|
||||
collapsed.** Those two columns carry the prose — how the report arrived, and the
|
||||
whole story of the vulnerability — so the page would be one wall of text and the
|
||||
columns that identify a row (CVE, name, date, reporter, stars) would be far apart.
|
||||
In `hall-of-fame/index.html`, and ONLY in those two `<td>`s, the cell's content
|
||||
sits inside a `<details>` whose summary is the column's own name — `Process` for
|
||||
the Process cell, `Details` for the Vulnerabilities cell:
|
||||
|
||||
```
|
||||
<td valign="top">
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
<ul>
|
||||
<li>… the same list as before …</li>
|
||||
</ul>
|
||||
</details>
|
||||
</td>
|
||||
```
|
||||
|
||||
The summary is that plain word, there is a blank line under it, and the cell's
|
||||
contents are otherwise unchanged. The other six cells of the row stay as they are
|
||||
— they are what a reader scans — and so does the header row.
|
||||
- **The vuln-name subdirectory page is NOT collapsed.** `hall-of-fame/<name>bleed/index.html`
|
||||
is the page a reader opened on purpose, for one vulnerability: its table cell stays
|
||||
open and its `<h2 class="hof">Details</h2>` prose section below the table stays as
|
||||
it is. Only the index, which lists them all, hides them behind the summary.
|
||||
|
||||
### Tests
|
||||
|
||||
- `build.sh` / `build.bat` have a menu to install dependencies, build
|
||||
WeKan and run tests.
|
||||
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
|
||||
have tests. When adding a test, run or validate it and fix it until it works.
|
||||
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
|
||||
one per run (e.g. `2026-07-21_20-58-09/`). That is **`../log/`**, one level up
|
||||
from the repo, so a run does not show up in `git status` - **unless the parent
|
||||
is not writable**, as in a Flatpak sandbox that shares only the repository;
|
||||
there it is **`./log/`** inside the repo (gitignored). `build.sh` prints which
|
||||
one when a run starts. The
|
||||
newest datetime directory is the latest run. Each holds the Playwright per-browser
|
||||
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
|
||||
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
|
||||
(the WeKan test server + database output). A whole-run directory also holds the
|
||||
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
|
||||
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
|
||||
the newest test logs" means: open the most recent `log/<datetime>/` - look in
|
||||
`../log/` first, then `./log/` - and read those.
|
||||
- **Check and fix WHILE the tests are still running.** A full run takes a long time
|
||||
(three browsers, then every database with an image for this CPU, then FerretDB's
|
||||
own suites), and its stages finish one at a time. Do not wait for the end: read
|
||||
the logs that are already written, fix what they show, and then look again for the
|
||||
stages that have finished since — repeat until every stage has run and everything
|
||||
found is fixed. Two things make this work:
|
||||
- A stage's log file is complete when its `===== ... finished` line is there; a
|
||||
file whose mtime is still moving is a stage in progress, and its failures so far
|
||||
are already real and worth fixing.
|
||||
- The node suites are run by `tests/run-node-suites.cjs`, which runs ALL of them
|
||||
and lists every failure at the end (`===== node suites: N run, M failed`). They
|
||||
are the fastest signal - about 15 seconds for 260 suites - so they are usually
|
||||
what to fix first while the browsers are still going.
|
||||
- **A failing guard is not automatically a broken app.** Most of these suites read
|
||||
the source and pin a behaviour. When one fails, decide which side is wrong: fix
|
||||
the CODE when the guard still describes what WeKan should do, and fix the GUARD
|
||||
when the behaviour deliberately changed - and then say in the test WHY, so the
|
||||
next reader knows it was a decision and not a slip. Every fixed test keeps the
|
||||
assertion that made it valuable; do not delete a test to make a run green.
|
||||
|
||||
## Environment
|
||||
|
||||
- The editor (VSCode) runs inside a **Flatpak sandbox**, launched by
|
||||
`docs/Security/Sandboxes/vscode/vscode-sandbox.sh`. What the sandbox allows/blocks and
|
||||
how it is set up is documented in `docs/Security/Sandboxes/vscode/README.md` in that
|
||||
same directory — read it when something behaves differently than a normal host (file
|
||||
access, network, running services).
|
||||
|
||||
### Always validate from the actual code
|
||||
|
||||
- When doing anything, check how it actually works in the code first.
|
||||
+1980
-19
File diff suppressed because it is too large.
Load diff
@@ -14,8 +14,7 @@ git config user.name && git config user.email
|
||||
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
|
||||
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
|
||||
with no AI trailer and no pull request, and the **publishing / release steps** below
|
||||
are available. (Per the standing rule you still **commit only; do not push** unless
|
||||
explicitly asked.)
|
||||
are available.
|
||||
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
|
||||
the branch and do **not** run any release/publishing step. Make changes on a branch
|
||||
and open a **pull request** for the maintainer to review. The "commit as Lauri
|
||||
@@ -122,17 +121,21 @@ pushed to Transifex as if it were human.
|
||||
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
|
||||
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
|
||||
do the opposite: work on a branch and open a pull request — see the top section.)
|
||||
- **[maintainer only]** **Commit only. Do not push** (unless explicitly asked).
|
||||
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
|
||||
- `../w/wekan.fi` — the WeKan website.
|
||||
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
|
||||
- `.tools/` — companion repos cloned INSIDE this checkout, in one directory
|
||||
that `.gitignore` and `.meteorignore` already exclude (they used to be one
|
||||
ignored subdirectory each at the repo root). `build.sh`'s `ensure_tool_repo`
|
||||
clones one on demand, so a fresh checkout needs no manual setup:
|
||||
`.tools/FerretDB`, `.tools/node-patches`, `.tools/mongo-tools-patches`.
|
||||
- `.tools/FerretDB` (cloned on demand) — https://github.com/wekan/FerretDB, its own
|
||||
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
|
||||
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
|
||||
use `wekan/wekan#NNNN`.
|
||||
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
|
||||
- `.tools/sandstorm` (when present) — https://github.com/sandstorm-io/sandstorm.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
@@ -344,7 +347,7 @@ All publishing / release steps below are maintainer-only. Contributors never run
|
||||
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
|
||||
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
|
||||
is the only hand step.
|
||||
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
|
||||
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
|
||||
version). It renames `## Upcoming FerretDB release` to the next version with the
|
||||
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
|
||||
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
|
||||
|
||||
+1
-1
@@ -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.71
|
||||
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
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v10.71.0"
|
||||
appVersion: "v10.78.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -100,12 +100,14 @@ echo.
|
||||
echo -- Setup -- ^(0 = Back^)
|
||||
echo 1^) Install dependencies
|
||||
echo 2^) Build WeKan
|
||||
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
|
||||
echo 3^) git pull ^(fetch, fast-forward or rebase, repoint moved CHANGELOG links^)
|
||||
echo 4^) git push ^(check the CHANGELOG links, push, pull-and-retry once if rejected^)
|
||||
set "choice="
|
||||
set /p "choice=Choose: "
|
||||
if "%choice%"=="1" goto install
|
||||
if "%choice%"=="2" goto build
|
||||
if "%choice%"=="3" goto updategit
|
||||
if "%choice%"=="3" goto gitpull
|
||||
if "%choice%"=="4" goto gitpush
|
||||
if "%choice%"=="0" goto menu
|
||||
goto menu_setup
|
||||
|
||||
@@ -138,8 +140,10 @@ REM ===========================================================================
|
||||
:menu_tests
|
||||
echo.
|
||||
echo -- Tests -- ^(0 = Back^)
|
||||
echo 1^) EVERYTHING ^(sequential^): WeKan's own tests, then every database with an
|
||||
echo image for this CPU, then all FerretDB tests. Logs in ..\log\^<datetime^>\
|
||||
echo 1^) EVERYTHING ^(sequential^): the floating-promises guard, then WeKan's own
|
||||
echo tests ^(mocha, node suites, import, node E2E, three browsers^), then every
|
||||
echo database with an image for this CPU, then all FerretDB tests ^(unit, vet,
|
||||
echo integration^). Logs in ..\log\^<datetime^>\
|
||||
echo 2^) WeKan's own tests only, parallel: Mocha, node unit suites, import, node
|
||||
echo E2E and all three browsers, concurrently. No databases, no FerretDB
|
||||
echo 3^) WeKan's own tests only, sequential: the same suite, one job at a time
|
||||
@@ -299,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" ""
|
||||
@@ -556,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
|
||||
@@ -638,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)
|
||||
@@ -672,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)
|
||||
@@ -799,51 +811,32 @@ echo Done.
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
REM Make the working copy current in one step: fetch + rebase the current branch
|
||||
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
|
||||
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
|
||||
REM show the status. Parity with build.sh's Setup -> "Update git ...".
|
||||
:updategit
|
||||
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
|
||||
git rev-parse --git-dir >nul 2>&1
|
||||
if errorlevel 1 ( echo Not a git repository. & goto end )
|
||||
set "BRANCH="
|
||||
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
|
||||
echo Branch: %BRANCH%
|
||||
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
|
||||
echo --- git fetch --all --prune ---
|
||||
git fetch --all --prune
|
||||
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo No upstream origin/%BRANCH% - skipping rebase.
|
||||
) else (
|
||||
echo --- git pull --rebase --autostash origin %BRANCH% ---
|
||||
git pull --rebase --autostash origin "%BRANCH%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Rebase stopped ^(conflicts^). Resolve them, then:
|
||||
echo git rebase --continue ^(or: git rebase --abort^)
|
||||
echo and run this option again to finish the hash fix + status.
|
||||
goto end
|
||||
)
|
||||
)
|
||||
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
|
||||
echo ^(whole file, including released sections: bash releases/fix-changelog-hashes.sh --all-sections^)
|
||||
:gitpull
|
||||
REM Both git actions are build.sh's git_pull / git_push, run through bash rather
|
||||
REM than written a second time here. What they do - fast-forward or rebase,
|
||||
REM repoint the CHANGELOG links a rebase moved, abort a conflicting rebase so the
|
||||
REM repo is left exactly as it was, pull-and-retry once when a push is rejected -
|
||||
REM is shell logic, and the batch copy of the old "Update git" that used to live
|
||||
REM here is why this file drifted: it reimplemented the same steps, so a fix to
|
||||
REM one never reached the other.
|
||||
where bash >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
|
||||
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
|
||||
) else (
|
||||
bash releases/fix-changelog-hashes.sh
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
goto end
|
||||
)
|
||||
echo --- git status ---
|
||||
git status
|
||||
echo.
|
||||
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
|
||||
echo commit it - this script never commits for you.
|
||||
bash ./build.sh git-pull
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
:gitpush
|
||||
where bash >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
goto end
|
||||
)
|
||||
bash ./build.sh git-push
|
||||
goto end
|
||||
|
||||
:dev_local
|
||||
call :ensure_dirs
|
||||
call :set_dev_env
|
||||
@@ -949,8 +942,10 @@ call :rebuild_for_tests
|
||||
if errorlevel 1 goto end
|
||||
|
||||
set "FAILED=0"
|
||||
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
|
||||
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
|
||||
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
|
||||
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
|
||||
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
|
||||
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
|
||||
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
|
||||
REM logs are never overwritten and previous runs are kept. PowerShell gives a
|
||||
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
|
||||
@@ -960,7 +955,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
|
||||
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
|
||||
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
|
||||
REM Clear completion flags from any previous run.
|
||||
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
|
||||
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
|
||||
|
||||
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
|
||||
REM Meteor build (.meteor\local-test); launching it here would make two full
|
||||
@@ -982,11 +977,19 @@ start "Wekan import" /MIN /D "%REPO%" cmd /c "(echo ===== Import regression [no
|
||||
|
||||
if "!SERVER_READY!"=="0" (
|
||||
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
|
||||
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
|
||||
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
|
||||
) else (
|
||||
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium + Firefox + WebKit, --workers=3^) in parallel.
|
||||
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium, Firefox and WebKit as three separate jobs^) in parallel.
|
||||
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
|
||||
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=3 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
|
||||
REM One job, one log, one summary row PER BROWSER - the same shape build.sh
|
||||
REM produces. A single combined "browsers" job wrote one wekan-alltests-browsers.log
|
||||
REM for all three, so "which browser failed, and what did WebKit print" could not
|
||||
REM be answered from the logs, and CLAUDE.md's "check the newest test logs" names
|
||||
REM the per-browser files. --output keeps each browser's artifacts in its own
|
||||
REM directory, or the three would clear each other's at startup.
|
||||
call :start_browser_job chromium
|
||||
call :start_browser_job firefox
|
||||
call :start_browser_job webkit
|
||||
)
|
||||
|
||||
REM Live progress: re-print a status line every ~3s until all expected jobs
|
||||
@@ -1001,18 +1004,25 @@ call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
|
||||
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
|
||||
if "!SERVER_READY!"=="1" (
|
||||
call :jstate e2e
|
||||
call :jstate browsers
|
||||
call :jstate chromium
|
||||
call :jstate firefox
|
||||
call :jstate webkit
|
||||
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
|
||||
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
|
||||
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
|
||||
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
|
||||
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
|
||||
)
|
||||
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e! ^| browsers [M1 :3000/db:3001] !S_browsers! tests:!C_browsers!
|
||||
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e!
|
||||
echo chromium !S_chromium! tests:!C_chromium! ^| firefox !S_firefox! tests:!C_firefox! ^| webkit !S_webkit! tests:!C_webkit! [all three on M1 :3000/db:3001]
|
||||
set "ALLDONE=1"
|
||||
if not exist ".done-mocha" set "ALLDONE=0"
|
||||
if not exist ".done-unit" set "ALLDONE=0"
|
||||
if not exist ".done-import" set "ALLDONE=0"
|
||||
if "!SERVER_READY!"=="1" (
|
||||
if not exist ".done-e2e" set "ALLDONE=0"
|
||||
if not exist ".done-browsers" set "ALLDONE=0"
|
||||
if not exist ".done-chromium" set "ALLDONE=0"
|
||||
if not exist ".done-firefox" set "ALLDONE=0"
|
||||
if not exist ".done-webkit" set "ALLDONE=0"
|
||||
)
|
||||
if "!ALLDONE!"=="0" (
|
||||
ping -n 4 127.0.0.1 >nul
|
||||
@@ -1029,7 +1039,9 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
|
||||
if "!S_unit!"=="FAIL" set "FAILED=1"
|
||||
if "!S_import!"=="FAIL" set "FAILED=1"
|
||||
if "!S_e2e!"=="FAIL" set "FAILED=1"
|
||||
if "!S_browsers!"=="FAIL" set "FAILED=1"
|
||||
if "!S_chromium!"=="FAIL" set "FAILED=1"
|
||||
if "!S_firefox!"=="FAIL" set "FAILED=1"
|
||||
if "!S_webkit!"=="FAIL" set "FAILED=1"
|
||||
|
||||
echo.
|
||||
echo ==================== TEST SUMMARY ====================
|
||||
@@ -1038,9 +1050,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
|
||||
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
|
||||
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
|
||||
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
|
||||
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
|
||||
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
|
||||
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
|
||||
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
|
||||
echo =====================================================
|
||||
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
|
||||
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
|
||||
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
|
||||
goto end
|
||||
|
||||
@@ -1066,8 +1080,10 @@ call :rebuild_for_tests
|
||||
if errorlevel 1 goto end
|
||||
|
||||
set "FAILED=0"
|
||||
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
|
||||
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
|
||||
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
|
||||
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
|
||||
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
|
||||
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
|
||||
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
|
||||
REM logs are never overwritten and previous runs are kept. PowerShell gives a
|
||||
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
|
||||
@@ -1077,7 +1093,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
|
||||
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
|
||||
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
|
||||
REM Clear completion flags from any previous run.
|
||||
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
|
||||
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
|
||||
|
||||
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
|
||||
REM Meteor build (.meteor\local-test); launching it here would make two full
|
||||
@@ -1116,15 +1132,23 @@ echo ==^> Running Node E2E regressions on Meteor #1 [Node.js :3000, MongoDB :300
|
||||
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
|
||||
call :seq_run_wait e2e e2e C_e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
|
||||
|
||||
echo ==^> Running Playwright Chromium, Firefox and WebKit one at a time ^(--workers=1^) on Meteor #1 [Node.js :3000, MongoDB :3001]. Full log: %RUN_LOGDIR%\wekan-alltests-browsers.log
|
||||
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call :onelog playwright-all
|
||||
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'" 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
|
||||
call :seq_run_wait browsers check C_browsers "%RUN_LOGDIR%\wekan-alltests-browsers.log"
|
||||
echo ==^> Running Playwright Chromium, Firefox and WebKit one browser at a time on Meteor #1 [Node.js :3000, MongoDB :3001].
|
||||
REM One job, one log and one summary row per browser, as build.sh does. The
|
||||
REM combined run wrote a single wekan-alltests-browsers.log for all three, so
|
||||
REM neither "which browser failed" nor "what did WebKit print" was answerable
|
||||
REM from the logs afterwards - and CLAUDE.md's "check the newest test logs"
|
||||
REM names wekan-alltests-chromium.log, -firefox.log and -webkit.log.
|
||||
call :start_browser_job chromium
|
||||
call :seq_run_wait chromium check C_chromium "%RUN_LOGDIR%\wekan-alltests-chromium.log"
|
||||
call :start_browser_job firefox
|
||||
call :seq_run_wait firefox check C_firefox "%RUN_LOGDIR%\wekan-alltests-firefox.log"
|
||||
call :start_browser_job webkit
|
||||
call :seq_run_wait webkit check C_webkit "%RUN_LOGDIR%\wekan-alltests-webkit.log"
|
||||
goto server_jobs_done
|
||||
|
||||
:skip_server_jobs
|
||||
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
|
||||
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
|
||||
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
|
||||
|
||||
:server_jobs_done
|
||||
|
||||
@@ -1138,14 +1162,18 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
|
||||
if "!S_unit!"=="FAIL" set "FAILED=1"
|
||||
if "!S_import!"=="FAIL" set "FAILED=1"
|
||||
if "!S_e2e!"=="FAIL" set "FAILED=1"
|
||||
if "!S_browsers!"=="FAIL" set "FAILED=1"
|
||||
if "!S_chromium!"=="FAIL" set "FAILED=1"
|
||||
if "!S_firefox!"=="FAIL" set "FAILED=1"
|
||||
if "!S_webkit!"=="FAIL" set "FAILED=1"
|
||||
|
||||
REM Count passing tests per job from each log (advances shown in the summary).
|
||||
call :jcount C_mocha check "%RUN_LOGDIR%\wekan-alltests-mocha.log"
|
||||
call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
|
||||
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
|
||||
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
|
||||
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
|
||||
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
|
||||
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
|
||||
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
|
||||
|
||||
echo.
|
||||
echo ==================== TEST SUMMARY ====================
|
||||
@@ -1154,9 +1182,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
|
||||
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
|
||||
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
|
||||
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
|
||||
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
|
||||
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
|
||||
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
|
||||
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
|
||||
echo =====================================================
|
||||
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
|
||||
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
|
||||
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
|
||||
goto end
|
||||
|
||||
@@ -1252,17 +1282,34 @@ if /i "%INSTALL_DEPS%"=="y" (
|
||||
popd
|
||||
)
|
||||
|
||||
REM All three browsers run natively on Windows. Use --workers=1 so a single
|
||||
REM Playwright run executes Chromium, Firefox and WebKit tests one at a time
|
||||
REM (sequentially) rather than concurrently: running all three at once uses too
|
||||
REM much RAM/swap and can crash the machine. WEKAN_PLAYWRIGHT_ALL=1 enables all
|
||||
REM projects.
|
||||
cd /d "%REPO%\tests\playwright"
|
||||
set "WEKAN_PLAYWRIGHT_ALL=1"
|
||||
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list
|
||||
if errorlevel 1 ( echo RESULT: Some Playwright browsers FAILED ^(see details above^). ) else ( echo RESULT: All Playwright browsers passed. )
|
||||
REM One browser at a time, each with its own log - the same as build.sh's
|
||||
REM run_playwright_parallel, and the same per-browser logs the whole-suite runs
|
||||
REM write. Running all three concurrently against one dev server uses too much
|
||||
REM RAM/swap on smaller machines and can crash it, so this stays sequential.
|
||||
REM
|
||||
REM It used to be a single `playwright test --project=... --project=...` call
|
||||
REM with no log at all: everything went to the terminal and there was nothing
|
||||
REM left to read afterwards, which is what tests/dbConformanceWiring.test.cjs
|
||||
REM means by "every Tests option writes its log to ../log/<datetime>/".
|
||||
set "PW_ALL_FAILED=0"
|
||||
for %%B in (chromium firefox webkit) do call :pw_one_browser %%B
|
||||
if "%PW_ALL_FAILED%"=="1" ( echo RESULT: Some Playwright browsers FAILED ^(see the logs above^). ) else ( echo RESULT: All Playwright browsers passed. )
|
||||
goto end
|
||||
|
||||
:pw_one_browser
|
||||
REM One browser of the "ALL browsers" option: %1 = chromium^|firefox^|webkit.
|
||||
REM Streams live AND writes wekan-playwright-^<browser^>.log, via the same
|
||||
REM :onelog helper every other Tests option uses.
|
||||
set "PW_PROJECT=%~1"
|
||||
call :onelog playwright-%PW_PROJECT%
|
||||
echo ==^> Playwright %PW_PROJECT% ^(one browser at a time^). Log: %ONELOG%
|
||||
pushd "%REPO%\tests\playwright"
|
||||
set "WEKAN_PLAYWRIGHT_ALL=1"
|
||||
call meteor npm exec playwright test -- --project=%PW_PROJECT% --output=test-results\%PW_PROJECT% --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'"
|
||||
if errorlevel 1 set "PW_ALL_FAILED=1"
|
||||
popd
|
||||
exit /b 0
|
||||
|
||||
REM ===========================================================================
|
||||
:check_floating
|
||||
echo Ensuring missing ESLint dependencies for the no-floating-promises rule.
|
||||
@@ -1724,6 +1771,25 @@ REM 6) Wait for :3000 to answer (bundle boots in seconds; curl-timeout poll).
|
||||
call :wait_server_ready
|
||||
exit /b 0
|
||||
|
||||
:start_browser_job
|
||||
REM Start ONE Playwright browser as its own job: %1 = chromium^|firefox^|webkit.
|
||||
REM
|
||||
REM One job, one log, one .done flag and one summary row per browser - the same
|
||||
REM shape build.sh's run_pw_all_browser produces, and what CLAUDE.md means by the
|
||||
REM per-browser logs in log\^<datetime^>\. The combined three-project run this
|
||||
REM replaced wrote everything into one wekan-alltests-browsers.log, where a
|
||||
REM failure could not be attributed to a browser without reading the whole file.
|
||||
REM
|
||||
REM --output is per browser as well: Playwright CLEARS its output directory at
|
||||
REM startup, so three jobs sharing test-results\ would delete each other's traces
|
||||
REM and screenshots - exactly the artifacts wanted after a failure. The job runs
|
||||
REM in tests\playwright, so the .done flag is written two levels up, in the repo
|
||||
REM root, where the wait loops look for it.
|
||||
if "%~1"=="" exit /b 1
|
||||
echo ==^> Playwright %~1 on Meteor #1 [Node.js :3000, MongoDB :3001]. Log: %RUN_LOGDIR%\wekan-alltests-%~1.log
|
||||
start "Wekan %~1" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright %~1 [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & call meteor npm exec playwright test -- --project=%~1 --output=test-results\%~1 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-%~1) else (echo PASS>..\..\.done-%~1)"
|
||||
exit /b 0
|
||||
|
||||
:seq_run_wait
|
||||
REM Live progress for a sequential job that runs in its own minimized window and
|
||||
REM writes .done-<key> when finished. %1=key %2=count kind (check^|e2e) %3=count var
|
||||
@@ -1761,8 +1827,8 @@ exit /b 0
|
||||
REM ===========================================================================
|
||||
:test_all_databases
|
||||
REM The same thing build.sh's "All databases (sequential)" runs: build the newest
|
||||
REM FerretDB v1 from the FerretDB subdirectory (cloning wekan/FerretDB if it is
|
||||
REM not there, and installing Go and the module dependencies if they are
|
||||
REM FerretDB v1 from .tools\FerretDB (cloning wekan/FerretDB there if it is not
|
||||
REM there, and installing Go and the module dependencies if they are
|
||||
REM missing), then run the whole FerretDB v1 query catalogue against every
|
||||
REM database that has a Docker image for THIS CPU - one at a time, because they
|
||||
REM all use the same FerretDB port - and compare that they all answered the same.
|
||||
@@ -1782,9 +1848,13 @@ goto end
|
||||
|
||||
REM ===========================================================================
|
||||
:test_ferretdb
|
||||
REM All of FerretDB's own tests, one at a time: unit, vet, integration. FerretDB
|
||||
REM is expected to be a subdirectory of this repo - the "All databases" option
|
||||
REM clones it if it is not there. Its build.sh installs Go and the Go modules
|
||||
REM All of FerretDB's own tests, one at a time: unit, vet, integration.
|
||||
REM
|
||||
REM FerretDB lives in .tools\FerretDB - companion repos are kept in one directory
|
||||
REM that .gitignore and .meteorignore already exclude, instead of one ignored
|
||||
REM subdirectory each at the repo root. It is cloned here when it is not there,
|
||||
REM the same as build.sh's ensure_tool_repo does, so neither script depends on
|
||||
REM the other having been run first. Its build.sh installs Go and the Go modules
|
||||
REM when they are missing, and writes its logs to ..\log\<datetime>\ with every
|
||||
REM other test run's.
|
||||
where bash >nul 2>&1
|
||||
@@ -1792,13 +1862,27 @@ if errorlevel 1 (
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
goto end
|
||||
)
|
||||
if not exist "FerretDB\build.sh" (
|
||||
echo FerretDB\build.sh is missing. Clone it first:
|
||||
echo git clone git@github.com:wekan/FerretDB
|
||||
echo ^(or run Tests - All databases, which clones it before building.^)
|
||||
if not exist "%REPO%\.tools\FerretDB\build.sh" (
|
||||
where git >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: git was not found, so .tools\FerretDB cannot be cloned.
|
||||
echo Install Git for Windows, or clone it by hand:
|
||||
echo git clone git@github.com:wekan/FerretDB .tools/FerretDB
|
||||
goto end
|
||||
)
|
||||
if not exist "%REPO%\.tools" md "%REPO%\.tools"
|
||||
echo ==^> FerretDB is not in .tools\ yet; cloning wekan/FerretDB
|
||||
git clone git@github.com:wekan/FerretDB "%REPO%\.tools\FerretDB"
|
||||
if errorlevel 1 (
|
||||
echo ==^> SSH clone failed ^(no key for github.com?^); trying HTTPS.
|
||||
git clone https://github.com/wekan/FerretDB "%REPO%\.tools\FerretDB"
|
||||
)
|
||||
)
|
||||
if not exist "%REPO%\.tools\FerretDB\build.sh" (
|
||||
echo ERROR: .tools\FerretDB\build.sh is still missing after cloning.
|
||||
goto end
|
||||
)
|
||||
bash -c "cd FerretDB && ./build.sh test-all"
|
||||
bash -c "cd .tools/FerretDB && ./build.sh test-all"
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
|
||||
@@ -83,6 +83,52 @@ fi
|
||||
export WEKAN_LOG_ROOT
|
||||
mkdir -p "$WEKAN_LOG_ROOT"
|
||||
|
||||
# ── Companion repositories: wekan/.tools/<name> ──────────────────────────────
|
||||
#
|
||||
# The repos WeKan needs to run everything - wekan/FerretDB for the database
|
||||
# conformance run and FerretDB's own suites, and the patch repos - are separate
|
||||
# git repositories, not part of this one. They live in .tools/ inside this
|
||||
# checkout, which .gitignore and .meteorignore already exclude, so a clone of
|
||||
# them can never end up in a commit or in a Meteor rebuild.
|
||||
#
|
||||
# They used to be cloned as subdirectories of the repo root (wekan/FerretDB),
|
||||
# each needing its own ignore entry; one ignored directory holds all of them.
|
||||
# The repo directory, from the script's own path rather than the caller's cwd:
|
||||
# ./build.sh is normally run from the checkout, but a clone must not land in
|
||||
# whatever directory somebody happened to be in.
|
||||
WEKAN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WEKAN_TOOLS_DIR="$WEKAN_DIR/.tools"
|
||||
export WEKAN_DIR WEKAN_TOOLS_DIR
|
||||
|
||||
# ensure_tool_repo <name> [git-url] - the path to .tools/<name>, cloning it if it
|
||||
# is not there yet. Prints the path on stdout; everything else goes to stderr, so
|
||||
# a caller can do `dir="$(ensure_tool_repo FerretDB)"`.
|
||||
#
|
||||
# SSH first, HTTPS second: a maintainer has a key and wants to push, and anyone
|
||||
# else still gets a working clone. Returns non-zero when neither works, so the
|
||||
# caller can say what it cannot do rather than cd into nothing.
|
||||
function ensure_tool_repo(){
|
||||
local name="$1"
|
||||
local url="${2:-git@github.com:wekan/$name}"
|
||||
local https_url="${url/git@github.com:/https://github.com/}"
|
||||
local dir="$WEKAN_TOOLS_DIR/$name"
|
||||
|
||||
if [ -d "$dir/.git" ]; then
|
||||
printf '%s\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$WEKAN_TOOLS_DIR" || { echo "ERROR: cannot create $WEKAN_TOOLS_DIR" >&2; return 1; }
|
||||
echo "==> $name is not in .tools/ yet; cloning $url" >&2
|
||||
if ! git clone "$url" "$dir" >&2; then
|
||||
echo "==> SSH clone failed (no key for github.com?); trying HTTPS." >&2
|
||||
if ! git clone "$https_url" "$dir" >&2; then
|
||||
echo "ERROR: could not clone $name from $url or $https_url" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$dir"
|
||||
}
|
||||
|
||||
function pause(){
|
||||
read -p "$*"
|
||||
}
|
||||
@@ -1126,17 +1172,67 @@ function run_all_tests(){
|
||||
return "$FAILED"
|
||||
}
|
||||
|
||||
# floating_promises_checks — the guard's CHECKS, with nothing that installs or
|
||||
# edits anything.
|
||||
#
|
||||
# The menu option of the same name may install ripgrep and the @typescript-eslint
|
||||
# packages and write the rule into .eslintrc.json: that is a person choosing to
|
||||
# set the guard up, interactively. EVERYTHING runs unattended, must not call
|
||||
# sudo, and must not modify the tree it is testing - a run that edits
|
||||
# .eslintrc.json is no longer testing the commit it started from. So the checks
|
||||
# themselves live here, are pure reads, and are what both callers run:
|
||||
#
|
||||
# * the no-floating-promises rule is configured in .eslintrc.json;
|
||||
# * every Authentication.checkBoardAccess / checkBoardWriteAccess in
|
||||
# server/models is awaited. An unawaited permission check returns a pending
|
||||
# promise, and a promise is truthy, so the call site passes a check that
|
||||
# never ran - which is a permission bug that looks like working code.
|
||||
#
|
||||
# grep, not ripgrep: this must work on a machine where rg is not installed and
|
||||
# where nothing may be installed to make it work.
|
||||
function floating_promises_checks(){
|
||||
local rc=0 calls
|
||||
echo "Checking that @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
|
||||
if grep -q '"@typescript-eslint/no-floating-promises"' .eslintrc.json 2>/dev/null; then
|
||||
echo "OK: the rule is configured."
|
||||
else
|
||||
echo "FAIL: @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json."
|
||||
echo " Tests -> 'Floating-promises guard' installs the packages and writes it."
|
||||
rc=1
|
||||
fi
|
||||
echo
|
||||
echo "Scanning server/models for unawaited Authentication.checkBoardAccess / checkBoardWriteAccess"
|
||||
calls="$(grep -RInE 'Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' server/models 2>/dev/null \
|
||||
| grep -vE 'await Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' || true)"
|
||||
if [ -n "$calls" ]; then
|
||||
echo "FAIL: these board auth checks are not awaited:"
|
||||
printf '%s\n' "$calls"
|
||||
rc=1
|
||||
else
|
||||
echo "OK: every board auth check in server/models is awaited."
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
# run_everything — every test WeKan and FerretDB have, one after another.
|
||||
#
|
||||
# Three stages, sequential on purpose: the WeKan suite (which builds a fresh
|
||||
# bundle and starts a server), then the database conformance run (which builds
|
||||
# FerretDB from source and runs one query catalogue against every database with an
|
||||
# image for this CPU), then FerretDB's own tests (unit, vet, integration). They
|
||||
# share one log/<datetime>/ directory, so "the newest test logs" is one place.
|
||||
# Four stages, sequential on purpose: the static guard (seconds, so a broken
|
||||
# permission check is reported before an hour of browsers), then the WeKan suite
|
||||
# (which builds a fresh bundle and starts a server, and runs mocha, the node
|
||||
# suites, the import regression, the node E2E and all three browsers), then the
|
||||
# database conformance run (which builds FerretDB from source and runs one query
|
||||
# catalogue against every database with an image for this CPU), then FerretDB's
|
||||
# own tests (unit, vet - which includes its no-LFS guard - and integration).
|
||||
# They share one log/<datetime>/ directory, so "the newest test logs" is one
|
||||
# place.
|
||||
#
|
||||
# FerretDB is expected to be a subdirectory of this repo. releases/db-conformance.sh
|
||||
# clones it if it is not there, so the only thing this checks is that the clone
|
||||
# worked before handing it the FerretDB stage.
|
||||
# Everything in the Tests menu that is a TEST is in here. What is deliberately
|
||||
# not: "Install Playwright browsers" (setup, and the browser stages install what
|
||||
# they need), and "Count tests by category" (a report, not a check).
|
||||
#
|
||||
# FerretDB lives in .tools/FerretDB, cloned on demand by ensure_tool_repo (and by
|
||||
# releases/db-conformance.sh, which needs it a stage earlier). .tools/ is ignored
|
||||
# by git and by Meteor, so a companion repo cannot reach a commit or a rebuild.
|
||||
function run_everything(){
|
||||
local RUN_TS RUN_LOGDIR FAILED=0
|
||||
RUN_TS="$(date '+%Y-%m-%d_%H-%M-%S')"
|
||||
@@ -1147,20 +1243,30 @@ function run_everything(){
|
||||
|
||||
echo "=============================================================================="
|
||||
echo "EVERYTHING, one stage at a time. Logs: $RUN_LOGDIR/"
|
||||
echo " 1/3 WeKan's own tests (builds the bundle, starts a server)"
|
||||
echo " 2/3 Database conformance (builds FerretDB, every database this CPU runs)"
|
||||
echo " 3/3 FerretDB's own tests (unit, vet, integration)"
|
||||
echo " 1/4 Floating-promises guard (seconds: the rule, and unawaited auth checks)"
|
||||
echo " 2/4 WeKan's own tests (builds the bundle, starts a server; mocha, the"
|
||||
echo " node suites, import, node E2E, three browsers)"
|
||||
echo " 3/4 Database conformance (builds FerretDB, every database this CPU runs)"
|
||||
echo " 4/4 FerretDB's own tests (unit, vet, integration)"
|
||||
echo "This takes a long time. Nothing runs concurrently, so a failure is readable."
|
||||
echo "=============================================================================="
|
||||
echo
|
||||
|
||||
local wekan_rc=0 conf_rc=0 ferret_rc=0
|
||||
local guard_rc=0 wekan_rc=0 conf_rc=0 ferret_rc=0
|
||||
|
||||
echo "### 1/3 WeKan tests ###########################################################"
|
||||
echo "### 1/4 Floating-promises guard ###############################################"
|
||||
# Checks only - no installing, no editing of .eslintrc.json. It takes seconds,
|
||||
# so it runs first: an unawaited permission check should not be reported after
|
||||
# an hour of browser tests.
|
||||
floating_promises_checks 2>&1 | tee "$RUN_LOGDIR/wekan-floating-promises.log"
|
||||
guard_rc=${PIPESTATUS[0]}
|
||||
|
||||
echo
|
||||
echo "### 2/4 WeKan tests ###########################################################"
|
||||
run_all_tests sequential || wekan_rc=$?
|
||||
|
||||
echo
|
||||
echo "### 2/3 Database conformance ##################################################"
|
||||
echo "### 3/4 Database conformance ##################################################"
|
||||
if [ -x ./releases/db-conformance.sh ]; then
|
||||
./releases/db-conformance.sh || conf_rc=$?
|
||||
else
|
||||
@@ -1168,24 +1274,29 @@ function run_everything(){
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "### 3/3 FerretDB tests ########################################################"
|
||||
if [ -x FerretDB/build.sh ]; then
|
||||
( cd FerretDB && WEKAN_LOGDIR="$RUN_LOGDIR" ./build.sh test-all ) || ferret_rc=$?
|
||||
echo "### 4/4 FerretDB tests ########################################################"
|
||||
# .tools/FerretDB, cloned here if this is the first run on this machine -
|
||||
# stage 3 usually got there first, but EVERYTHING must not depend on the
|
||||
# order of its own stages.
|
||||
ferret_dir="$(ensure_tool_repo FerretDB)" || ferret_dir=""
|
||||
if [ -n "$ferret_dir" ] && [ -x "$ferret_dir/build.sh" ]; then
|
||||
( cd "$ferret_dir" && WEKAN_LOGDIR="$RUN_LOGDIR" ./build.sh test-all ) || ferret_rc=$?
|
||||
else
|
||||
echo "ERROR: FerretDB/build.sh is missing - db-conformance.sh clones wekan/FerretDB"
|
||||
echo " into this repo; run stage 2 first, or clone it by hand."
|
||||
echo "ERROR: .tools/FerretDB/build.sh is missing and could not be cloned."
|
||||
echo " Clone it by hand: git clone git@github.com:wekan/FerretDB .tools/FerretDB"
|
||||
ferret_rc=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================== EVERYTHING ==================================="
|
||||
printf ' %-6s %s\n' "$([ $wekan_rc -eq 0 ] && echo PASS || echo FAIL)" "WeKan tests (sequential)"
|
||||
printf ' %-6s %s\n' "$([ $guard_rc -eq 0 ] && echo PASS || echo FAIL)" "Floating-promises guard (rule + unawaited board auth checks)"
|
||||
printf ' %-6s %s\n' "$([ $wekan_rc -eq 0 ] && echo PASS || echo FAIL)" "WeKan tests (mocha, node suites, import, E2E, three browsers)"
|
||||
printf ' %-6s %s\n' "$([ $conf_rc -eq 0 ] && echo PASS || echo FAIL)" "Database conformance (all databases for this CPU)"
|
||||
printf ' %-6s %s\n' "$([ $ferret_rc -eq 0 ] && echo PASS || echo FAIL)" "FerretDB tests (unit, vet, integration)"
|
||||
echo "=============================================================================="
|
||||
echo "All logs: $RUN_LOGDIR/"
|
||||
unset WEKAN_LOGDIR
|
||||
[ $wekan_rc -eq 0 ] && [ $conf_rc -eq 0 ] && [ $ferret_rc -eq 0 ] || FAILED=1
|
||||
[ $guard_rc -eq 0 ] && [ $wekan_rc -eq 0 ] && [ $conf_rc -eq 0 ] && [ $ferret_rc -eq 0 ] || FAILED=1
|
||||
if [ "$FAILED" -eq 0 ]; then echo "RESULT: EVERYTHING passed."; else echo "RESULT: something FAILED - see the stage summaries above."; fi
|
||||
return "$FAILED"
|
||||
}
|
||||
@@ -1526,63 +1637,161 @@ function ask_dev_url(){
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Update git: make the working copy current in one step ────────────────────
|
||||
# ── Keeping the checkout in step with origin ─────────────────────────────────
|
||||
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
|
||||
# commit links the rebase made stale (same shared script release-all.sh uses), and
|
||||
# show the resulting status. One menu action instead of the fetch/pull/rebase/
|
||||
# hash-fix/status dance by hand.
|
||||
function update_git(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 0; }
|
||||
local branch upstream here
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
||||
echo "== Update git — branch: ${branch:-?} =="
|
||||
# ── git pull / git push, doing the whole job ─────────────────────────────────
|
||||
#
|
||||
# These replace the old "Update git", which did a fetch+rebase, ran the CHANGELOG
|
||||
# hash repair, printed `git status` and left the rest to the reader. The rest is
|
||||
# where the mistakes were: a rebase rewrites local commits, so the CHANGELOG links
|
||||
# written before it point at commits that no longer exist; the repair fixes the
|
||||
# file but commits nothing, so the next push carries a dirty tree; and a push
|
||||
# rejected as non-fast-forward leaves a repo that needs a pull the caller has to
|
||||
# know to run.
|
||||
#
|
||||
# So: one action per direction, each ending in a state that is either correct or
|
||||
# unchanged. Conflicts are the one thing a script must not paper over - a rebase
|
||||
# that stops half-applied IS the wrong state - so on conflict the rebase is
|
||||
# aborted, the working tree is exactly what it was, and the message says what to
|
||||
# do by hand.
|
||||
#
|
||||
# Nothing here force-pushes, ever.
|
||||
|
||||
# Do not surprise anyone: a dirty tree is stashed by --autostash below, but say
|
||||
# so first and let the user opt out.
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
echo "Working tree has uncommitted changes:"
|
||||
git status --short
|
||||
printf "They will be auto-stashed for the rebase and re-applied after. Continue? [y/N] "
|
||||
read -r ans
|
||||
case "$ans" in y|Y|yes|YES) : ;; *) echo "Aborted (nothing changed)."; return 0 ;; esac
|
||||
fi
|
||||
|
||||
echo "--- git fetch --all --prune ---"
|
||||
git fetch --all --prune || { echo "fetch failed - check network/remote and retry."; return 0; }
|
||||
|
||||
upstream="origin/$branch"
|
||||
if git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
|
||||
echo "--- git pull --rebase --autostash origin $branch ---"
|
||||
if ! git pull --rebase --autostash origin "$branch"; then
|
||||
echo
|
||||
echo "Rebase stopped (conflicts or autostash conflict). Resolve them, then:"
|
||||
echo " git rebase --continue # or: git rebase --abort"
|
||||
echo "and run this option again to finish the hash fix + status."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "No upstream '$upstream' - skipping rebase (nothing to pull for this branch)."
|
||||
fi
|
||||
|
||||
# The rebase may have rewritten commits the CHANGELOG links to; repoint the
|
||||
# stale links in the unreleased section (shared with releases/release-all.sh).
|
||||
# A link is only repointed when NO ref in this clone reaches its commit, so a
|
||||
# link into an old release tag - which GitHub serves fine - is left alone.
|
||||
echo "--- Fixing CHANGELOG commit links (releases/fix-changelog-hashes.sh) ---"
|
||||
bash "$here/releases/fix-changelog-hashes.sh" || true
|
||||
echo " (whole file, including released sections:"
|
||||
echo " bash releases/fix-changelog-hashes.sh --all-sections [--dry-run])"
|
||||
|
||||
echo "--- git status ---"
|
||||
git status
|
||||
# The CHANGELOG links after history moved. The repair only repoints a link whose
|
||||
# commit is reachable from NO ref in this clone (a link into an old release tag
|
||||
# is fine and is left alone), so running it when nothing moved is a no-op.
|
||||
# Returns 0 when the file was changed and committed, 1 when there was nothing to do.
|
||||
function git_fix_changelog_links(){
|
||||
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
|
||||
[ -f "$script" ] || return 1
|
||||
bash "$script" || true
|
||||
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
|
||||
echo
|
||||
echo "NOTE: CHANGELOG.md was updated by the hash fix above."
|
||||
echo " Review 'git diff CHANGELOG.md' and commit it (this script never commits for you)."
|
||||
git add CHANGELOG.md
|
||||
git commit -q -m "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 !"
|
||||
echo "==> CHANGELOG commit links repointed and committed."
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# git pull: fast-forward when that is all it takes, rebase when the branch has
|
||||
# diverged, and never end half-way through either.
|
||||
function git_pull(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
|
||||
local branch upstream before ahead behind
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
upstream="origin/$branch"
|
||||
echo "== git pull - branch $branch =="
|
||||
|
||||
# The exact commit to return to if anything goes wrong.
|
||||
before="$(git rev-parse HEAD)"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "==> Working tree is not clean; the changes are stashed for the pull and"
|
||||
echo " re-applied afterwards (git's own --autostash)."
|
||||
fi
|
||||
|
||||
echo "--- git fetch origin $branch ---"
|
||||
git fetch origin "$branch" || { echo "ERROR: fetch failed - network or remote. Nothing changed."; return 1; }
|
||||
git rev-parse --verify --quiet "$upstream" >/dev/null || {
|
||||
echo "No $upstream yet - nothing to pull. Push first to create it."; return 0; }
|
||||
|
||||
behind="$(git rev-list --count HEAD.."$upstream")"
|
||||
ahead="$(git rev-list --count "$upstream"..HEAD)"
|
||||
echo " $ahead commit(s) here that origin does not have, $behind the other way."
|
||||
|
||||
if [ "$behind" -eq 0 ]; then
|
||||
echo "==> Already up to date with $upstream."
|
||||
elif [ "$ahead" -eq 0 ]; then
|
||||
# Nothing of ours to replay: a fast-forward moves the branch pointer and
|
||||
# rewrites no commit, so no link can go stale.
|
||||
echo "--- fast-forward (no local commits to replay) ---"
|
||||
git merge --ff-only "$upstream" || { echo "ERROR: fast-forward failed. Nothing changed."; return 1; }
|
||||
else
|
||||
echo "--- rebase: replaying $ahead local commit(s) onto $upstream ---"
|
||||
echo " This gives them NEW hashes, which is why the CHANGELOG links are"
|
||||
echo " repaired straight after."
|
||||
if ! git -c rebase.autoStash=true rebase "$upstream"; then
|
||||
git rebase --abort 2>/dev/null
|
||||
git stash list >/dev/null 2>&1
|
||||
echo
|
||||
echo "ERROR: the rebase hit a conflict, so it was ABORTED - this repo is"
|
||||
echo " exactly as it was before ($(git rev-parse --short "$before"))."
|
||||
echo " Resolve it by hand:"
|
||||
echo " git rebase $upstream # then fix the conflicts"
|
||||
echo " git rebase --continue # or: git rebase --abort"
|
||||
echo " and run this option again afterwards."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve; nothing to repoint."
|
||||
echo "--- git status ---"
|
||||
git status --short --branch
|
||||
return 0
|
||||
}
|
||||
|
||||
# git push: make sure what is about to be published is correct first, and turn
|
||||
# the one rejection that has an obvious answer into that answer.
|
||||
function git_push(){
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
|
||||
local branch ahead
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "== git push - branch $branch =="
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "==> NOTE: there are uncommitted changes; a push publishes commits only."
|
||||
git status --short
|
||||
fi
|
||||
|
||||
# Before publishing, not after: a stale link that reaches GitHub 404s for
|
||||
# everyone who reads the release notes.
|
||||
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve."
|
||||
|
||||
git fetch origin "$branch" >/dev/null 2>&1 || true
|
||||
if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then
|
||||
ahead="$(git rev-list --count "origin/$branch"..HEAD)"
|
||||
[ "$ahead" -eq 0 ] && { echo "==> Nothing to push; origin/$branch is already at this commit."; return 0; }
|
||||
echo " $ahead commit(s) to push."
|
||||
fi
|
||||
|
||||
echo "--- git push origin $branch ---"
|
||||
if git push origin "$branch"; then
|
||||
echo "==> Pushed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# The common rejection: origin moved while this was being written. That has
|
||||
# one right answer - pull, then push again - so do it once, rather than
|
||||
# printing the advice and leaving the caller to it. Once only: a second
|
||||
# rejection is something else, and retrying a loop is not a fix.
|
||||
echo
|
||||
echo "==> Push was rejected. Pulling first, then trying once more."
|
||||
git_pull || { echo "ERROR: the pull did not finish, so nothing was pushed."; return 1; }
|
||||
echo "--- git push origin $branch (retry) ---"
|
||||
if git push origin "$branch"; then
|
||||
echo "==> Pushed."
|
||||
return 0
|
||||
fi
|
||||
echo
|
||||
echo "ERROR: still rejected. This is NOT a case to force - force-pushing a"
|
||||
echo " branch somebody has pulled rewrites their history too. Read what"
|
||||
echo " git said above; if origin has commits this clone should keep,"
|
||||
echo " run this option's pull first and look at 'git log --oneline'."
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
echo
|
||||
PS3='Please enter your choice: '
|
||||
|
||||
@@ -1664,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|||"
|
||||
@@ -1702,6 +1912,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
|
||||
"Translations|Report English strings that regressed|releases/translations/report-english-regressions.mjs|||"
|
||||
"Translations|Prove a pull keeps human translations (no network)|releases/translations/verify-human-preference.mjs|||"
|
||||
"Translations|Merge a finished pull by hand|releases/translations/merge-translations.mjs|||"
|
||||
"Git and repo|git pull - fetch, fast-forward or rebase, repoint moved CHANGELOG links|!git_pull|||git-pull"
|
||||
"Git and repo|git push - verify the CHANGELOG links, push, pull-and-retry once if origin moved|!git_push|||git-push"
|
||||
"Git and repo|Commit with the editor open for a multi-line message|releases/commit.sh|||"
|
||||
"Git and repo|Add everything, then revert it again|!git restore --staged|Path to unstage||git-add-revert"
|
||||
"Git and repo|Delete a branch, locally and on the remote|releases/delete-branch-local-and-remote.sh|Branch name||"
|
||||
@@ -1910,7 +2122,8 @@ while [ -z "$opt" ]; do
|
||||
choose "Setup" \
|
||||
"Install dependencies|Install WeKan dependencies" \
|
||||
"Build WeKan|Build WeKan" \
|
||||
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status" ;;
|
||||
"git pull|git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts" \
|
||||
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile" ;;
|
||||
"Dev server")
|
||||
choose "Dev server" \
|
||||
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
|
||||
@@ -1923,7 +2136,7 @@ while [ -z "$opt" ]; do
|
||||
"Kill all dev servers|Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)" ;;
|
||||
"Tests")
|
||||
choose "Tests" \
|
||||
"EVERYTHING (sequential): WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/" \
|
||||
"EVERYTHING (sequential): the guard + WeKan tests + all databases + FerretDB tests|Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/" \
|
||||
"WeKan's own tests only, parallel|Run WeKan's own tests in parallel on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, concurrently. No database conformance and no FerretDB tests - logs in log/<datetime>/" \
|
||||
"WeKan's own tests only, sequential|Run WeKan's own tests sequentially on http://localhost:3000: Mocha, the node unit suites, import regression, node E2E and all three browsers, one job at a time. No database conformance and no FerretDB tests - logs in log/<datetime>/" \
|
||||
"Mocha (server-side)|Test Mocha unit + security + API-logic tests (server-side only, no browser)" \
|
||||
@@ -2052,8 +2265,13 @@ for _once in 1; do
|
||||
break
|
||||
;;
|
||||
|
||||
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
|
||||
update_git
|
||||
"git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts")
|
||||
git_pull
|
||||
break
|
||||
;;
|
||||
|
||||
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
|
||||
git_push
|
||||
break
|
||||
;;
|
||||
|
||||
@@ -2261,39 +2479,15 @@ for _once in 1; do
|
||||
echo "Ensuring .eslintrc.json includes @typescript-eslint plugin and no-floating-promises rule"
|
||||
node -e "const fs=require('fs');const p='.eslintrc.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.plugins=Array.isArray(c.plugins)?c.plugins:[];if(!c.plugins.includes('@typescript-eslint'))c.plugins.push('@typescript-eslint');c.rules=c.rules||{};c.rules['@typescript-eslint/no-floating-promises']='error';fs.writeFileSync(p,JSON.stringify(c,null,2)+'\\n');"
|
||||
|
||||
echo "Checking whether @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
RULE_CHECK_CMD='rg -n'
|
||||
else
|
||||
RULE_CHECK_CMD='grep -nE'
|
||||
fi
|
||||
|
||||
if $RULE_CHECK_CMD '"@typescript-eslint/no-floating-promises"' .eslintrc.json >/dev/null 2>&1; then
|
||||
echo "OK: Rule @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
|
||||
else
|
||||
echo "WARNING: Rule @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json"
|
||||
echo "Suggested: add @typescript-eslint/eslint-plugin and set '@typescript-eslint/no-floating-promises': 'error'"
|
||||
fi
|
||||
|
||||
echo "Quick note: @typescript-eslint/no-floating-promises is a type-aware rule and may require further parser/project setup for full enforcement in all files."
|
||||
|
||||
echo
|
||||
echo "Scanning for unawaited Authentication.checkBoardAccess/checkBoardWriteAccess in server/models"
|
||||
AUTH_CALL_PATTERN='Authentication\.checkBoardAccess\(|Authentication\.checkBoardWriteAccess\('
|
||||
AUTH_AWAIT_PATTERN='await Authentication\.checkBoardAccess\(|await Authentication\.checkBoardWriteAccess\('
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
if rg -n "$AUTH_CALL_PATTERN" server/models | rg -v "$AUTH_AWAIT_PATTERN"; then
|
||||
echo "WARNING: Found possible unawaited board auth checks above"
|
||||
else
|
||||
echo "OK: No unawaited board auth checks found"
|
||||
fi
|
||||
else
|
||||
if grep -RInE "$AUTH_CALL_PATTERN" server/models | grep -vE "$AUTH_AWAIT_PATTERN"; then
|
||||
echo "WARNING: Found possible unawaited board auth checks above"
|
||||
else
|
||||
echo "OK: No unawaited board auth checks found"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The checks themselves - the same function EVERYTHING runs, so the
|
||||
# menu option and the whole-run stage can never drift apart. Above
|
||||
# this line is the setting-up half, which only a person asking for
|
||||
# this option gets: it installs packages and writes the rule into
|
||||
# .eslintrc.json, and a test run must do neither.
|
||||
floating_promises_checks
|
||||
} 2>&1 | tee "$LOG"
|
||||
break
|
||||
;;
|
||||
@@ -2320,7 +2514,7 @@ for _once in 1; do
|
||||
fi
|
||||
;;
|
||||
|
||||
"Run EVERYTHING sequentially: WeKan's own tests, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
|
||||
"Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
|
||||
run_everything
|
||||
;;
|
||||
|
||||
@@ -2410,5 +2604,21 @@ for _once in 1; do
|
||||
break
|
||||
;;
|
||||
|
||||
# No arm matched. $opt is the menu entry's FULL DESCRIPTION, written twice -
|
||||
# once in the choose() list far above, once as the arm here - so an edit to
|
||||
# the menu text that misses the arm leaves an option that matches nothing.
|
||||
# Without this, the case simply falls through, the `for _once` loop ends and
|
||||
# the script EXITS: the option looks like it worked, printed nothing and ran
|
||||
# nothing. That is what renaming "EVERYTHING (sequential)" did to it.
|
||||
# tests/buildScriptParity.test.cjs fails on the mismatch itself; this is what
|
||||
# the person in front of the menu sees if one ever gets past it.
|
||||
*)
|
||||
echo "build.sh: no handler for this menu option - that is a bug in build.sh." >&2
|
||||
echo " The menu entry's description and its case arm must be the SAME string:" >&2
|
||||
echo " $opt" >&2
|
||||
echo " Nothing was run. Please report it, or run the same thing from the command line:" >&2
|
||||
echo " ./build.sh --list" >&2
|
||||
;;
|
||||
|
||||
esac
|
||||
done
|
||||
@@ -249,6 +249,11 @@
|
||||
text-align: center;
|
||||
}
|
||||
.minicard .minicard-title {
|
||||
/* #6465: one step below the list heading above it (16px), and above the
|
||||
"Add card" affordance below (13px). It used to inherit the 16px document
|
||||
size, which made a card title exactly as loud as the heading of the column
|
||||
holding it. See the note on .list-header-name in lists/list.css. */
|
||||
font-size: 14px;
|
||||
margin-inline-end: 21px;
|
||||
/* #6444: isolate each title's bidirectional context. The title is a
|
||||
dir="auto" .viewer; under a global dir="rtl" document, non-isolated
|
||||
|
||||
@@ -119,6 +119,7 @@ body.list-resizing-active * {
|
||||
}
|
||||
|
||||
/* More aggressive override for any container that might interfere */
|
||||
.js-lists .list.list-resizing,
|
||||
.js-swimlane .list.list-resizing,
|
||||
.dragscroll .list.list-resizing,
|
||||
[id^="swimlane-"] .list.list-resizing {
|
||||
@@ -140,6 +141,18 @@ body.list-resizing-active * {
|
||||
property, and without :not() this !important rule overrode the 30px collapsed
|
||||
width, so collapsing a list that had a custom width left it full width.
|
||||
See https://github.com/wekan/wekan/issues/5892 */
|
||||
/* #6573: `.js-lists` is the selector that has to be here, and `.dragscroll` is
|
||||
the one that cannot be relied on. In LISTS view the container is
|
||||
`.swimlane.list-group.js-lists.dragscroll` - no `js-swimlane` class and no
|
||||
`swimlane-<id>` id - so `.dragscroll` was the only one of the three that
|
||||
matched, and the width of every list hung on it. Starting a card drag calls
|
||||
suspendBoardDragscroll(), which REMOVES the dragscroll class from the board so
|
||||
the same pointer cannot pan and drag at once (#6558); this rule stopped
|
||||
matching with it, every list fell back to the flex rules below, and the whole
|
||||
board re-flowed to fill the window the instant a card was picked up - then
|
||||
snapped back on drop, when the class returned. Both views' containers carry
|
||||
`js-lists`, and nothing removes it. */
|
||||
.js-lists .list[style*="--list-width"]:not(.list-collapsed),
|
||||
.js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
|
||||
.dragscroll .list[style*="--list-width"]:not(.list-collapsed),
|
||||
[id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
|
||||
@@ -163,6 +176,7 @@ body.list-resizing-active * {
|
||||
in mobile mode on a desktop browser the inline width was there and the lists stayed
|
||||
at their desktop width while everything else was in mobile layout. Same three
|
||||
selectors, prefixed with body.mobile-mode so this one is the more specific. */
|
||||
body.mobile-mode .js-lists .list[style*="--list-width"]:not(.list-collapsed),
|
||||
body.mobile-mode .js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
|
||||
body.mobile-mode .dragscroll .list[style*="--list-width"]:not(.list-collapsed),
|
||||
body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
|
||||
@@ -454,11 +468,20 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
/* #6465: the type scale of a list, from the outside in. A list heading is to
|
||||
its cards what an h2 is to its paragraphs, and it had ended up SMALLER than
|
||||
them: the heading was 14px while a minicard title and the "Add card" link
|
||||
both inherited the 16px document size, so the column looked like it was
|
||||
labelled in fine print and the affordance to add a card shouted louder than
|
||||
the cards. The scale is now heading 16 > card title 14 >= add-card link 13,
|
||||
kept in that order by tests/listTypeScale.test.cjs. Mobile keeps its own
|
||||
larger sizes (the !important rules further down) - a phone is read at arm's
|
||||
length. */
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
min-height: 14px;
|
||||
min-height: 16px;
|
||||
min-width: 56px;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
@@ -732,6 +755,23 @@ form.inlined-form .list-composer {
|
||||
.list-header .highlight {
|
||||
color: #ce1414;
|
||||
}
|
||||
/* #6465: the WIP counter belongs on the title line, and the header of a list
|
||||
that has one must be as tall as its neighbours - the report showed "(2/5)"
|
||||
dropped onto a second line under the title, making that one column's header
|
||||
taller than the lists either side of it.
|
||||
The counter is already inside the h2, right after the title (listHeader.jade),
|
||||
but the title is a `+viewer`, and `.viewer` is display: block with a 22px
|
||||
min-height: a block in the middle of a heading pushes everything after it onto
|
||||
the next line and reserves a band under it. Inline, the counter stays where the
|
||||
markup already puts it. Same treatment the quick-access header and the table
|
||||
view give their viewers. */
|
||||
.list-header .list-header-name .viewer,
|
||||
.list-header .list-header-name .viewer p {
|
||||
display: inline;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-header .cardCount {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
@@ -784,12 +824,20 @@ form.inlined-form .list-composer {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.list-body .minicards .add-controls button {
|
||||
min-height: 50px;
|
||||
/* Was min-height: 50px - taller than a minicard is for its title, so the
|
||||
"Add" button was the biggest thing on the board while typing. 34px is a
|
||||
comfortable touch target that still reads as a control rather than a slab. */
|
||||
min-height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-body .open-minicard-composer {
|
||||
border-radius: 2px;
|
||||
color: #8c8c8c;
|
||||
display: block;
|
||||
/* Smaller than a card title, because it is not a card: it is the affordance
|
||||
for making one. At the inherited 16px it was the largest text in the list
|
||||
body, level with the board's real content. */
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -30,6 +30,8 @@ template(name="adminReports")
|
||||
+eventStreamReport(stream="cpu")
|
||||
else if showDatabase.get
|
||||
+eventStreamReport(stream="database")
|
||||
else if showIntegrity.get
|
||||
+eventStreamReport(stream="integrity")
|
||||
else if tablePageData
|
||||
+tablePage(tablePageData)
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ Template.adminReports.onCreated(function () {
|
||||
this.showTests = new ReactiveVar(false);
|
||||
this.showCpu = new ReactiveVar(false);
|
||||
this.showDatabase = new ReactiveVar(false);
|
||||
this.showIntegrity = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
@@ -315,6 +316,10 @@ const PROBLEMS_MENU = [
|
||||
// What the database itself said, classified: which database type, what it
|
||||
// means and what to do (server/lib/databaseProblems.js).
|
||||
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
|
||||
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
|
||||
// stored, and whether this server stopped cleanly
|
||||
// (docs/Security/Remediation/WeKan.md §13).
|
||||
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
|
||||
];
|
||||
|
||||
Template.adminReports.helpers({
|
||||
@@ -497,6 +502,7 @@ function openReportPane(tmpl, targetID) {
|
||||
tmpl.showTests.set(false);
|
||||
tmpl.showCpu.set(false);
|
||||
tmpl.showDatabase.set(false);
|
||||
tmpl.showIntegrity.set(false);
|
||||
if (tmpl.subscription) {
|
||||
tmpl.subscription.stop();
|
||||
}
|
||||
@@ -533,6 +539,9 @@ function openReportPane(tmpl, targetID) {
|
||||
} else if ('report-database' === targetID) {
|
||||
tmpl.showDatabase.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-integrity' === targetID) {
|
||||
tmpl.showIntegrity.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-broken' === targetID) {
|
||||
// A report like the others now: same controls, same paging, same loader.
|
||||
tmpl.brokenPage.set(1);
|
||||
@@ -835,8 +844,28 @@ const EVENT_STREAM_COLUMNS = [
|
||||
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
|
||||
{ labelKey: 'event-action', value: r => r.action || r.type },
|
||||
{ labelKey: 'event-source', value: r => r.source },
|
||||
// The user who triggered the event (e.g. who uploaded a sanitized file).
|
||||
{ labelKey: 'username', value: r => userName(r.userId), userId: r => r.userId },
|
||||
// WHO tried it. The stored `username` wins over looking the account up: it is
|
||||
// what the account was CALLED when the event happened, so a later rename does
|
||||
// not rewrite history and a deleted account does not erase it. The lookup
|
||||
// stays as the fallback for the older events that predate the stored field.
|
||||
{
|
||||
labelKey: 'username',
|
||||
value: r => r.username || userName(r.userId),
|
||||
userId: r => r.userId,
|
||||
},
|
||||
// FROM WHERE. Resolved with the same spoofing-safe rule as the login throttle -
|
||||
// X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - so this
|
||||
// column cannot be written by sending a header.
|
||||
{ labelKey: 'event-ip', nowrap: true, value: r => r.ip || '' },
|
||||
// HOW MANY attempts this row stands for. A canary counts repeats inside its
|
||||
// window rather than writing one row each, so "1" is an ordinary event and a
|
||||
// larger number is a burst that was deliberately not written out in full
|
||||
// (docs/Security/Remediation/WeKan.md §12).
|
||||
{
|
||||
labelKey: 'event-attempts',
|
||||
nowrap: true,
|
||||
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
|
||||
},
|
||||
// The `database` stream's detail is WeKan's reading of the error - what it means
|
||||
// and what to do - and it is worth little without the sentence the database
|
||||
// itself produced, which is the one thing an admin can search for or paste into
|
||||
|
||||
@@ -238,3 +238,21 @@
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* #6465: "Please move the button down. This is too close" - Save sat directly
|
||||
under the 1..28 day buttons of the monthly schedule, close enough to be hit
|
||||
while aiming for a date. The gap is on the form-group that FOLLOWS the day
|
||||
grid, so it applies wherever that grid is used and nowhere else. */
|
||||
.attachments-content .schedule-day-buttons + .form-group,
|
||||
.schedule-day-buttons + .form-group {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* #6465: "The alignment is out of place" - the move button and the labelled
|
||||
selects beside it are one row (align-items: flex-end), so a margin of the
|
||||
button's own would offset it from the controls it acts on. Zero it, and let
|
||||
the row do the aligning. */
|
||||
.attachments-content .move-storage-form > button {
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
@@ -264,3 +264,19 @@ table:not(.table-page-table) td:first-child {
|
||||
(docs/Design/Page/Table.md), so its controls row, pagination and table are
|
||||
styled there - and its pager is themed by paginationControls.css like every
|
||||
other pager. Nothing pane-specific is needed here any more. */
|
||||
|
||||
/* #6465: the "all users" select-all in a table heading. One row, box after the
|
||||
word, with a gap that cannot collapse - the two used to be loose inline nodes
|
||||
in a centred heading cell, and at the column's width the box sat on top of the
|
||||
text. inline-flex keeps the heading's own centring intact. */
|
||||
.select-all-user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.select-all-user input.allUserChkBox {
|
||||
margin: 0;
|
||||
flex: none;
|
||||
}
|
||||
@@ -101,8 +101,14 @@ template(name="peopleGeneral")
|
||||
+tablePage(this)
|
||||
|
||||
template(name="selectAllUser")
|
||||
| {{_ 'dueCardsViewChange-choice-all'}}
|
||||
input.allUserChkBox(type="checkbox", id="chkSelectAll")
|
||||
//- #6465: label AND box in one <label>, so the box cannot land on top of the
|
||||
//- word. They were two loose inline nodes in a table heading: the heading
|
||||
//- centres its content, the checkbox is replaced-content with its own
|
||||
//- baseline, and at the width the column happens to have, the box overlapped
|
||||
//- the text. A label also makes the word itself click the box.
|
||||
label.select-all-user(for="chkSelectAll")
|
||||
span {{_ 'dueCardsViewChange-choice-all'}}
|
||||
input.allUserChkBox(type="checkbox", id="chkSelectAll")
|
||||
|
||||
template(name="domainGeneral")
|
||||
+tablePage(tablePageData)
|
||||
|
||||
@@ -266,10 +266,16 @@ template(name="changeSettingsPopup")
|
||||
option(value="#{day.value}") #{day.name}
|
||||
label.bold.clear
|
||||
| {{_ 'card-settings'}}
|
||||
//- #6465: the checkbox and its label are ONE row, built the way every
|
||||
//- other checkbox row in this file is (see the board list above):
|
||||
//- a .flex anchor, the .materialCheckBox, then the text. It used to
|
||||
//- carry a `b ` spacer and `.left` (float: inline-start) as
|
||||
//- well - a float inside a flex container is ignored, so the spacer
|
||||
//- became a stray flex item and the box drifted away from the text it
|
||||
//- belongs to, up beside the "Card settings" heading above.
|
||||
ul#cards.card-description-rescued
|
||||
a.flex.js-rescue-card-description(title="{{_ 'rescue-card-description'}}")
|
||||
b
|
||||
#rescue-card-description.materialCheckBox.left(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
|
||||
#rescue-card-description.materialCheckBox(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
|
||||
span {{_ 'rescue-card-description'}}
|
||||
//- "Save", and themed. It said "Apply" while every other settings form in
|
||||
//- WeKan says Save, and it carried no `.primary` class - so it fell to the
|
||||
|
||||
@@ -9,7 +9,6 @@ import './responsive.tests';
|
||||
import './boardNumbering.tests';
|
||||
import './subtaskStatus.tests';
|
||||
import './subtaskViewGuard.tests';
|
||||
import './boardTriggersClass.tests';
|
||||
import './calendarFirstDay.tests';
|
||||
import './mentionEnterGuard.tests';
|
||||
import './subtaskViewTarget.tests';
|
||||
|
||||
@@ -725,6 +725,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -727,6 +727,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -714,6 +714,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -731,6 +731,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -621,6 +621,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -855,6 +855,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -685,6 +685,14 @@ services:
|
||||
# must still be configured for any group search to work.
|
||||
#- LDAP_GROUP_FILTER_ENABLE=false
|
||||
#
|
||||
# Where the GROUPS are, when they are not under LDAP_BASEDN. Leave this
|
||||
# unset when users and groups share one subtree: the group searches then
|
||||
# use LDAP_BASEDN, exactly as they always have. Set it when the directory
|
||||
# keeps them apart - ou=groups beside ou=people is a common layout - or
|
||||
# every group search runs under the USER base, finds nothing, and
|
||||
# LDAP_GROUP_FILTER_ENABLE=true refuses every login (wekan/wekan#5539).
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#
|
||||
# The object class for filtering. Example: group
|
||||
#- LDAP_GROUP_FILTER_OBJECTCLASS=
|
||||
#
|
||||
|
||||
@@ -77,6 +77,7 @@ The bare `/admin/settings` still resolves; it *redirects* to
|
||||
| `/admin/problems/cards` | Cards Report | `report-cards` |
|
||||
| `/admin/problems/recovery` | Recovery | `report-recovery` |
|
||||
| `/admin/problems/database` | Database problems | `report-database` |
|
||||
| `/admin/problems/integrity` | Filesystem integrity | `report-integrity` |
|
||||
|
||||
Two panes are called Security, and they are different things: the *settings*
|
||||
pane and the *report*. `security` is the settings one, beside `notifications`
|
||||
|
||||
@@ -341,6 +341,13 @@ services:
|
||||
# LDAP_BASEDN : The base DN for the LDAP Tree
|
||||
# example : LDAP_BASEDN=ou=user,dc=example,dc=org
|
||||
- LDAP_BASEDN=ou=prod,dc=mydomain,dc=com
|
||||
# LDAP_GROUP_BASEDN : Where the GROUPS are, when they are not under
|
||||
# LDAP_BASEDN. Leave unset when users and groups share one subtree - then
|
||||
# the group searches use LDAP_BASEDN, exactly as before. Set it when the
|
||||
# directory keeps them apart (ou=groups beside ou=people), or the group
|
||||
# searches find nothing and LDAP_GROUP_FILTER_ENABLE refuses every login.
|
||||
# example : LDAP_GROUP_BASEDN=ou=groups,dc=example,dc=org
|
||||
#- LDAP_GROUP_BASEDN=ou=groups,dc=mydomain,dc=com
|
||||
# LDAP_LOGIN_FALLBACK : Fallback on the default authentication method
|
||||
# example : LDAP_LOGIN_FALLBACK=true
|
||||
- LDAP_LOGIN_FALLBACK=false
|
||||
|
||||
@@ -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.71-win64.zip](https://github.com/wekan/wekan/releases/download/v10.71/wekan-10.71-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.71-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)
|
||||
|
||||
@@ -70,6 +70,86 @@ problem *observable* to WeKan; the recording is WeKan's job. Concretely:
|
||||
WeKan maps these to `eventlog` documents via the category catalog
|
||||
(`models/lib/securityCategories.js`) using `source:'sqlite.*'`.
|
||||
|
||||
---
|
||||
|
||||
## 3b. Canary tokens — operations the client never issues
|
||||
|
||||
The database is reached over a **local socket by one application**, whose driver
|
||||
is a Meteor 3 one. That makes a class of operations interesting **by their mere
|
||||
presence**: server-side JavaScript evaluation, an aggregation writing its result
|
||||
into a collection, dropping a database, a server-administration command. The
|
||||
driver does not send them. A request that does is either a bug or somebody who
|
||||
has reached the socket and is looking around — and both are worth telling the
|
||||
operator about.
|
||||
|
||||
`internal/util/canary` (paired with `tests` in the same package) is the FerretDB
|
||||
half of [WeKan.md §12](WeKan.md), and it keeps the same three properties:
|
||||
|
||||
- **SILENT.** A tripped canary returns the ordinary *"operation not supported by
|
||||
this build"* refusal — the same answer an unimplemented command gets. The id is
|
||||
appended as `canary:<id>`, which the client parses and the operator reads;
|
||||
nothing in it says that anything was detected or recorded, so a probe cannot
|
||||
tell a watched operation from an unimplemented one and route around the watched
|
||||
ones. A Go test asserts the message contains none of *detect*, *record*, *log*,
|
||||
*alert*.
|
||||
- **BOUNDED.** The package writes **nothing** — no file, no table, no counter. It
|
||||
is a map lookup and an error value, so a caller hammering it in a loop costs
|
||||
this process one string comparison per request and this package **no memory at
|
||||
all**. This is what §2's "FerretDB does not write to any database or file"
|
||||
means for canaries, and a Go test asserts the package is stateless.
|
||||
- **ATTRIBUTED.** The marker names *which* canary, so the operator's report says
|
||||
"tried to run server-side JavaScript" rather than "an error".
|
||||
|
||||
The canaries:
|
||||
|
||||
| Id | Operations | Why the client never sends it |
|
||||
| --- | --- | --- |
|
||||
| `db.javascript` | `eval`, `$where`, `$function`, `$accumulator`, `mapReduce` | the driver has no feature that evaluates JavaScript in the database |
|
||||
| `db.result-to-collection` | `$out`, `$merge` | aggregation results are read, never persisted by the database |
|
||||
| `db.drop-database` | `dropDatabase`, `dropAllDatabases` | the application drops collections it owns; dropping the database is an operator action taken with the database's own tools |
|
||||
| `db.server-admin` | `shutdown`, `setParameter`, `getParameter`, `profile`, `logRotate` | these manage the server, not the data |
|
||||
|
||||
`Check(op)` returns `nil` for everything else, and a Go test pins the ordinary
|
||||
vocabulary — `find`, `insert`, `update`, `aggregate`, `$match`, `$group`,
|
||||
`$lookup`, … — as **not** tripping. A canary that fires on normal traffic is
|
||||
worse than no canary: it buries the real ones.
|
||||
|
||||
### How it reaches the operator
|
||||
|
||||
Exactly as §2 describes for every other FerretDB event — the database reports,
|
||||
the client records:
|
||||
|
||||
```
|
||||
FerretDB WeKan
|
||||
Check(op) → canary:<id> in the error ──► recordDatabaseProblem()
|
||||
│ databaseCanaryId() reads the marker
|
||||
▼
|
||||
tripCanary('database.canary') ← rate-limited,
|
||||
│ aggregated,
|
||||
▼ attributed
|
||||
eventlog (stream:'security')
|
||||
│
|
||||
▼
|
||||
Admin Panel → Problems → Security
|
||||
```
|
||||
|
||||
Two things that are deliberate on the WeKan side
|
||||
(`server/lib/databaseProblems.js`):
|
||||
|
||||
1. The id is **not trusted as a category**. It is matched against a known list,
|
||||
and anything else is recorded generically. An error string is
|
||||
attacker-influenced, and a marker parsed out of one must never be able to
|
||||
choose which security category it lands in — or to inject anything, which is
|
||||
why the extractor accepts only `[a-z][a-z0-9.-]{0,60}`.
|
||||
2. A canary goes to the **security** stream and does **not** fall through to the
|
||||
`database` stream. Filing it under "the database said something" would put an
|
||||
intrusion attempt in the list of things to triage as configuration problems.
|
||||
|
||||
On **MongoDB** there is no FerretDB to mark anything, and these operations simply
|
||||
never appear — the WeKan side is inert, and the feature degrades to nothing
|
||||
rather than misbehaving. That is the same property as §2: storage and reporting
|
||||
are WeKan's job, so nothing here depends on which database is underneath.
|
||||
|
||||
## 4. Security remediation points → logger
|
||||
|
||||
| Point | Remediation (present/added) | Logged as |
|
||||
|
||||
@@ -351,7 +351,333 @@ test". It uses the same `events` schema and the same disk-space discipline, in t
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope / follow-ups
|
||||
## 12. Canary tokens — who tried it, and from where
|
||||
|
||||
§7 records what a guard **did**: a request was blocked, a filename sanitized, an
|
||||
upload refused. That answers "is WeKan defending itself". It does not answer the
|
||||
question an admin actually asks after seeing one — **who did that, and from
|
||||
where** — and it does not distinguish a browser that got confused from somebody
|
||||
working through the [hall of fame](https://wekan.fi/hall-of-fame/) one entry at
|
||||
a time.
|
||||
|
||||
A **canary token** is a tripwire placed at a point that **only a permission-override
|
||||
attempt reaches**. Ordinary use never gets there: the code path runs when a
|
||||
request asks for something the rules forbid. So a trip is not noise, and it is
|
||||
worth an admin's attention with the actor attached.
|
||||
|
||||
Three properties define one. Each is enforced by `tests/canaryTokens.test.cjs`
|
||||
and `tests/canaryCoverage.test.cjs`.
|
||||
|
||||
### 12.1 SILENT — the attacker learns nothing
|
||||
|
||||
Tripping a canary changes **nothing the caller can observe**. The refusal keeps
|
||||
its wording, its status, its timing and its shape. This is not politeness: a
|
||||
canary that announces itself is a map of which paths are watched, and a probe
|
||||
would simply avoid them.
|
||||
|
||||
So the trip function is written as a drop-in for the refusal it replaces:
|
||||
|
||||
```js
|
||||
if (somethingForbidden) return false; // before
|
||||
if (somethingForbidden) return tripCanary('card.vote-field', { userId }); // after
|
||||
```
|
||||
|
||||
`tripCanary()` **always returns `false`**, and `tripCanaryDeny()` — for a Meteor
|
||||
`deny` rule, which refuses by returning **true** — always returns `true`. The
|
||||
return value is decided **before** any reporting is attempted, and the reporting
|
||||
is wrapped, so a canary can never throw into, delay, or otherwise disturb the
|
||||
request it is watching. A REST handler that must throw re-throws **the original
|
||||
error object**, never a new one.
|
||||
|
||||
### 12.2 BOUNDED — probing must not cost more than it does now
|
||||
|
||||
A canary sits where an attacker can loop. Writing one database row per attempt
|
||||
would be a denial of service they get for free: CPU on every insert, and a
|
||||
collection that grows until the disk is full.
|
||||
|
||||
`models/lib/canaryTokens.js` (pure, no Meteor, no clock of its own — the runtime
|
||||
passes the time in, which is how the tests drive it) decides per trip:
|
||||
|
||||
- the **first** trip of a `(canary, actor)` pair is recorded immediately, so a
|
||||
probe shows up promptly;
|
||||
- further trips **inside the window** (default 60 s) write nothing and are
|
||||
**counted**;
|
||||
- the first trip **after** the window writes **one summary** carrying the total,
|
||||
which is what the `count` column shows;
|
||||
- a pair that has already written `maxEventsPerPair` summaries (default 60)
|
||||
keeps counting but **stops writing** until it goes quiet — so an endless slow
|
||||
probe cannot write one row a minute forever;
|
||||
- the map of tracked pairs is **capped** (default 5000) and evicts the **least
|
||||
recently seen** entry, so a long-running attacker is not pushed out by a
|
||||
passing one, and a botnet varying its address cannot grow it without bound;
|
||||
- idle pairs are **swept** on a timer, so a long-lived server does not hold a row
|
||||
per address seen since boot.
|
||||
|
||||
A thousand attempts in a minute therefore cost **one row**, and the decision to
|
||||
suppress costs **one map lookup** — which is what makes a canary safe to put on a
|
||||
path an attacker chooses to hammer. The username lookup is cached (60 s, capped
|
||||
at 1000 entries), so probing cannot make WeKan do a database read per attempt
|
||||
either, and neither the lookup nor the insert is awaited on the caller's path.
|
||||
|
||||
### 12.3 ATTRIBUTED — who, and from where
|
||||
|
||||
Every event carries:
|
||||
|
||||
| Field | Where it comes from |
|
||||
| --- | --- |
|
||||
| `userId` | the caller's own knowledge, else the DDP invocation |
|
||||
| `username` | looked up once and cached; **stored on the event**, not resolved later |
|
||||
| `ip` | the request, resolved with `resolveClientKey` — the SAME spoofing-safe rule as the login throttle |
|
||||
| `count` | how many attempts this one row stands for (§12.2) |
|
||||
|
||||
The username is **denormalised at write time on purpose**: it is what the account
|
||||
was *called when it tried*, so a later rename does not rewrite history and a
|
||||
deleted account does not erase it.
|
||||
|
||||
The address honours `X-Forwarded-For` **only as far as `HTTP_FORWARDED_COUNT`
|
||||
says to trust it**, exactly as `server/lib/loginAttemptThrottle.js` does.
|
||||
Otherwise an attacker would write somebody else's address into the security log
|
||||
by sending a header — turning the report into a way to frame a colleague.
|
||||
|
||||
`detail` says what was **attempted**, in the words of the feature ("tried to move
|
||||
a card into a board they cannot write to"), and **never the payload**: it is
|
||||
attacker-controlled text, and §4's truncation and control-character stripping is
|
||||
the second line of defence behind that rule.
|
||||
|
||||
### 12.4 Where the canaries are
|
||||
|
||||
Each is `canaryId → the guard that trips it → the published vulnerability whose
|
||||
ATTEMPT it watches`. The table is pinned by `tests/canaryCoverage.test.cjs`,
|
||||
which also fails on a canary in the catalog that nothing trips.
|
||||
|
||||
| Canary | Tripped in | Watches the attempt behind |
|
||||
| --- | --- | --- |
|
||||
| `card.cross-board-move` | `server/permissions/cards.js` | BoardBleed |
|
||||
| `card.invisible-parent` | `server/permissions/cards.js` | ParentBleed |
|
||||
| `card.vote-field`, `card.poker-field` | `server/permissions/cards.js` | direct field writes that must go through a method |
|
||||
| `list.cross-board-move` | `server/permissions/lists.js` | BoardBleed |
|
||||
| `swimlane.cross-board-move` | `server/permissions/swimlanes.js` | BoardBleed |
|
||||
| `checklist.cross-board-move` | `server/permissions/checklists.js` | ChecklistBleed |
|
||||
| `checklist-item.cross-board-move` | `server/permissions/checklistItems.js` | ChecklistBleed |
|
||||
| `avatar.version-path`, `avatar.restricted-field`, `avatar.not-owner` | `server/permissions/avatars.js` | PathBleed |
|
||||
| `attachment.version-path`, `attachment.restricted-field` | `server/permissions/attachments.js` | PathBleed |
|
||||
| `reaction.foreign` | `server/permissions/cardCommentReactions.js` | writing another user's reaction |
|
||||
| `comment.foreign-delete` | `server/models/cardComments.js` | CommentBleed |
|
||||
| `export.path-outside-storage` | `models/exporter.js` | PathBleed (a path poisoned some other way) |
|
||||
| `database.canary` | `server/lib/databaseProblems.js` | [FerretDB.md](FerretDB.md) — an operation WeKan never issues |
|
||||
|
||||
**No Playwright, no browser.** Every canary above fires inside a server-side
|
||||
permission check, so it works on any WeKan server with nothing installed. A
|
||||
published vulnerability whose attempt leaves no server-side trace — a fixed XSS,
|
||||
say, where the payload is refused in the browser — has no canary, and that is a
|
||||
statement of what is detectable rather than an omission.
|
||||
|
||||
### 12.5 What the admin sees
|
||||
|
||||
Admin Panel → **Problems → Security** (§8), unchanged except for three columns:
|
||||
**Username**, **IP address** and **Attempts**. Both new columns are **searchable**,
|
||||
because the thing an admin does with one security event is pivot on it: every
|
||||
other event from this address, every other event from this account.
|
||||
|
||||
A row reads:
|
||||
|
||||
```
|
||||
2026-08-09 14:02:11 · authz · PathBleed · high · detected · canary:avatar.version-path
|
||||
· mallory · 203.0.113.7 · 214
|
||||
· tried to write the on-disk path of an avatar; 214 attempts in this window
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12.6 Injection, sanitization, and the other common attacks
|
||||
|
||||
§12.4 watches **permission overrides**. Three more families are watched the same
|
||||
way, and each has the same problem in common: the defence already existed and was
|
||||
**silent**, so nobody ever learned that it had fired.
|
||||
|
||||
**NoSQL injection** (`models/lib/injectionDetect.js`, pure and unit-tested). Two
|
||||
shapes, and they are different problems:
|
||||
|
||||
- an **execution operator** in a client-supplied selector — `$where`,
|
||||
`$function`, `$accumulator`, `$out`, `$merge`. A selector is data; these turn
|
||||
it into code the database runs. → `injection.nosql-selector`
|
||||
- an **operator object where a scalar was expected** — `{"$ne": null}`,
|
||||
`{"$gt": ""}`, `{"$regex": ".*"}` in a field that should hold the string a
|
||||
user typed. This is how "match every row" is spelled in a document database
|
||||
and it needs no JavaScript at all. → `injection.nosql-operator`
|
||||
|
||||
The detector is deliberately narrow about the second: **every** key must be an
|
||||
operator. A plain object with ordinary keys is somebody sending the wrong type,
|
||||
which is a validation error, and calling it an attack would fill the report with
|
||||
noise. A `$` inside a *string* is text — a card titled `$100 refund` puts nobody
|
||||
in the security report.
|
||||
|
||||
**SQL injection.** WeKan builds no SQL, so it cannot detect this itself. The
|
||||
database does: FerretDB's `internal/util/sqlguard` refuses a statement carrying
|
||||
what only injection produces, and now **marks the refusal** so the attempt
|
||||
reaches the admin instead of dying in a log file
|
||||
([FerretDB.md §3b](FerretDB.md)). → `injection.sql-statement`
|
||||
|
||||
**Sanitization.** Sanitizing is routine — a filename gets trimmed, a pasted
|
||||
`<b>` gets stripped from a comment — and routine is not worth an admin's
|
||||
attention. What is worth it is sanitization that removed something that would
|
||||
have **done** something:
|
||||
|
||||
| Canary | Fires when |
|
||||
| --- | --- |
|
||||
| `sanitize.dangerous-filename` | an uploaded name carried an exploit pattern, invisible characters or URL encoding |
|
||||
| `sanitize.path-traversal` | a file path tried to leave its directory |
|
||||
| `sanitize.dangerous-content` | active markup was removed from an uploaded file (a script or `javascript:` URI inside an SVG) |
|
||||
| `sanitize.dangerous-text` | active markup was removed from submitted text (a comment carrying `<script>`, an `onerror=`, a `javascript:` URI) |
|
||||
|
||||
`removedActiveMarkup()` decides the last two by comparing what the sanitizer took
|
||||
**out**: text that never had any is not interesting, and text that still has it
|
||||
was not sanitized at all — which would be a different bug.
|
||||
|
||||
**Other common attacks.**
|
||||
|
||||
| Canary | Fires when |
|
||||
| --- | --- |
|
||||
| `spoof.forwarded-header` | `/metrics` denied a request that carried `X-Forwarded-For` — nothing legitimate sends one to an endpoint that does not trust it |
|
||||
| `brute.login-lockout` | a REST login was refused while the address was locked out, i.e. it had already failed the configured number of attempts in a row |
|
||||
|
||||
Both replaced a **direct `securityLog.record()`**. That matters for more than
|
||||
tidiness: those two paths are **unauthenticated**, so a bare `record()` was one
|
||||
database insert per request an attacker chose to send. Going through the canary
|
||||
gives them the §12.2 rate limit and the §12.3 attribution, and the responses —
|
||||
the 401 and the 429 — are untouched.
|
||||
|
||||
## 13. Filesystem storage integrity, crashes and downtime
|
||||
|
||||
§12 watches what somebody **tries to do through WeKan**. This section is about
|
||||
what happened to WeKan's own files and process **when nobody was asking it
|
||||
anything**.
|
||||
|
||||
WeKan's attachments and avatars are files under `WRITABLE_PATH`, and the database
|
||||
holds one document per file. **Nothing checked that the two still agree.** A file
|
||||
can be replaced, truncated, back-dated or deleted by anything that reaches the
|
||||
filesystem — a bad restore, a sync tool, a container rebuild, a shell on the
|
||||
volume — and WeKan would keep serving whatever is there now, silently.
|
||||
|
||||
### 13.1 The baseline, and the four hashes
|
||||
|
||||
One document per file in the existing WeKan database (`fileIntegrity`; **no new
|
||||
files under `WRITABLE_PATH`**, per §3): the path, the size, the modification
|
||||
time, and **md5, sha256 and sha512**.
|
||||
|
||||
Three digests, not one, because they answer different questions. **md5** is fast
|
||||
and is what most other tools print, so an admin can compare against a backup with
|
||||
the tool they already have — it is never alone here, because its collision
|
||||
resistance is gone. **sha256** is the working digest. **sha512** is a second,
|
||||
different-width one, and it earns its place on the day the two *disagree*: two
|
||||
digests over the same bytes cannot disagree, so when they do, the bytes were not
|
||||
read the same way twice — a failing disk, a partial write, a truncated copy —
|
||||
which is a different problem from a substitution and is reported as its own,
|
||||
**critical**, finding.
|
||||
|
||||
**ed25519 is the fourth, and it is not a hash — it is a signature**, which is the
|
||||
only thing that answers the question the three digests cannot: *who says these
|
||||
are the right hashes?* Anybody who can rewrite a file can rewrite a row of
|
||||
hashes. So each baseline entry is signed over a canonical line
|
||||
(`manifestLine()` — fixed field order, no JSON, whose key order and escaping are
|
||||
not guaranteed stable across versions), and the signature is verified on every
|
||||
scan. A record that does not match its signature is a **critical** finding, and
|
||||
it is never explained away by an ordinary edit: WeKan does not rewrite a baseline
|
||||
without re-signing it.
|
||||
|
||||
The key:
|
||||
|
||||
| Source | What it protects against |
|
||||
| --- | --- |
|
||||
| `WEKAN_INTEGRITY_PRIVATE_KEY` (PKCS#8 PEM), supplied by the operator and **never stored by WeKan** | an attacker who reaches the **database** as well as the disk |
|
||||
| otherwise, generated on first run and kept in the database | filesystem-only tampering — a restore, a sync tool, a container rebuild, a shell on the volume |
|
||||
|
||||
The second is the common case and is honest about not being more than that. A
|
||||
**malformed** supplied key is reported and the scan runs **without** signing
|
||||
rather than silently falling back to a generated one, which would look like it
|
||||
was working while checking nothing the operator meant.
|
||||
|
||||
### 13.2 The scan: once a day, when nothing else needs the machine
|
||||
|
||||
Reading every stored byte is the last thing that should compete with users, so
|
||||
the scan is paced by policy rather than by hope (`models/lib/fileIntegrity.js`,
|
||||
pure and time-injected):
|
||||
|
||||
- **once a day** (`intervalMs`), checked hourly — not on every restart;
|
||||
- **not while the machine is busy**: at or above **60% CPU** it does not start,
|
||||
and it stops if the load rises mid-run. It is never urgent;
|
||||
- **a pause between every file** — 50 ms, plus 20 ms per megabyte, so a
|
||||
directory of large files does not become a sustained read;
|
||||
- **a time budget** (15 minutes). A scan that stops and continues tomorrow is
|
||||
better than one that holds a machine down;
|
||||
- **one read per file for all three digests.** Three passes would be three times
|
||||
the disk for the same bytes.
|
||||
|
||||
A run that stopped early does **not** report the files it never reached as
|
||||
missing — it has not looked everywhere, and saying otherwise would be a lie.
|
||||
|
||||
### 13.3 The finding: changed, with no record saying why
|
||||
|
||||
Files change when people use WeKan; reporting that would be noise. So each change
|
||||
is classified against WeKan's own record of the file:
|
||||
|
||||
| Situation | Result |
|
||||
| --- | --- |
|
||||
| unchanged | nothing recorded |
|
||||
| changed, and WeKan has a record of the operation | `info` — reported once, then **re-baselined** so it does not repeat daily |
|
||||
| **changed, with nothing to account for it** | **the warning** — at least `medium`, and deliberately **not** re-baselined, so it keeps showing until somebody looks |
|
||||
| the record's signature does not verify | `critical`, never explained |
|
||||
| recorded and now missing | `high` |
|
||||
| present and never recorded | `low` — a leftover or a backup copy is not evidence of anything |
|
||||
|
||||
A modification time that moved **backwards** is called out separately: nothing
|
||||
does that by accident.
|
||||
|
||||
The event carries the **file's name and the finding, never its contents and never
|
||||
the digests** — an admin needs to know which file, not to read it here.
|
||||
|
||||
### 13.4 Crashes, downtime and errors nobody caught
|
||||
|
||||
Admin Panel → Problems answered "what did WeKan refuse" and "what did the
|
||||
database say", and not the plainest question: **did this server stop, and did it
|
||||
stop cleanly?**
|
||||
|
||||
A crash leaves nothing behind — the process is gone, so it cannot write a message
|
||||
about being gone. A **heartbeat** is the only thing that works: WeKan writes the
|
||||
time every minute, and the NEXT start reads it.
|
||||
|
||||
| What the next start finds | Recorded as |
|
||||
| --- | --- |
|
||||
| no previous run | nothing — a first run is not a problem |
|
||||
| last heartbeat with a **clean-shutdown mark** | nothing — a deliberate restart is not a problem |
|
||||
| a **short** gap, no mark | `low` — the shutdown hook may simply not have run |
|
||||
| a **long** gap, no mark | **`high`** — it stopped without shutting down cleanly, and here is how long it was down |
|
||||
|
||||
Recording every deliberate restart is how a Problems page becomes a page nobody
|
||||
opens, which is why the first two record nothing at all.
|
||||
|
||||
Two more land in the same stream, because they are the same question one level
|
||||
down: an **uncaught exception** and an **unhandled promise rejection**. This app
|
||||
turns the second into a process exit, so it is a crash *with a cause attached* —
|
||||
and the cause is worth recording before the process goes. The listeners are
|
||||
**added, never replaced**: removing whatever else is listening would change how
|
||||
the app handles its own failures.
|
||||
|
||||
### 13.5 Where it appears
|
||||
|
||||
A new event stream, `integrity`, in the same `eventlog` collection and the same
|
||||
Admin Panel → **Problems → Filesystem integrity** table as the others (§8), with
|
||||
the Username / IP address / Attempts columns §12.3 added. It counts towards the
|
||||
red **Problems** button like every other stream.
|
||||
|
||||
The `fileIntegrity` baseline itself is **never published and never client-writable**:
|
||||
it is a map of every file on the server, and the key document is a private key.
|
||||
Neither belongs on a client, admin or not — Admin Panel reads the **findings**,
|
||||
which are ordinary event rows.
|
||||
|
||||
---
|
||||
|
||||
## 14. Out of scope / follow-ups
|
||||
|
||||
- Rotating/retention: a cron trims `eventlog` to the newest N per stream (`remove` older docs).
|
||||
- Shipping logs to an external SIEM (kept local by design).
|
||||
|
||||
@@ -2339,5 +2339,8 @@
|
||||
"event-severity": "Severity",
|
||||
"event-action": "Action",
|
||||
"event-source": "Source",
|
||||
"event-detail": "Detail"
|
||||
}
|
||||
"event-detail": "Detail",
|
||||
"event-ip": "IP address",
|
||||
"event-attempts": "Attempts",
|
||||
"integrityReportTitle": "Filesystem integrity"
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"board-members-same-team-only": "Додај чланове табле само из истог тима",
|
||||
"comment-in-reply-to": "Одговор на навод",
|
||||
"comment-reply": "Одговори",
|
||||
"restrict-comment-editing": "Ограничи право свих који управљају списима да мењају или уклоне садржај из записника",
|
||||
"restrict-comment-editing": "Ограничи право свих водећих сарадника који управљају списима да мењају или уклоне садржај из записника",
|
||||
"due-date-changes": "Померање рока",
|
||||
"due-date-changed-times": "Рок је померан/пробијен %s пута",
|
||||
"error-user-notSameOrgOrTeam": "Можете додати само лица која долазе из исте странке или истог правног тима",
|
||||
@@ -110,13 +110,13 @@
|
||||
"allboards.workspace-color": "Боја",
|
||||
"allboards.delete-workspace-confirm": "Да ли сте сигурни да желите да се потпуно иселите из овог радног простора?",
|
||||
"allboards.delete-workspace-confirm-check": "Потврдите исељење из радног простора",
|
||||
"multi-selection-active": "Означите припадајуће списе",
|
||||
"multi-selection-active": "Припајање списа",
|
||||
"selected-label": "Ознаке:",
|
||||
"set-selected-starred": "Додели звездицу изабраним списима",
|
||||
"set-selected-unstarred": "Уклони звездицу са изабраних табли",
|
||||
"set-selected-home": "Постави да се прво ови списи наметну након пријаве",
|
||||
"home-board-badge": "Наметнути списи који се први приказују после пријаве",
|
||||
"home-board-empty": "Drag a board here to open it after login",
|
||||
"set-selected-starred": "Додели им звездице",
|
||||
"set-selected-unstarred": "Уклони им звездице",
|
||||
"set-selected-home": "Нек ме дочека",
|
||||
"home-board-badge": "Списи добродошлице",
|
||||
"home-board-empty": "Овде сместите списе за добродошлицу",
|
||||
"home-board-remove": "Remove from Home",
|
||||
"home-board-remove-confirm": "Remove this board from Home? The board itself is not deleted.",
|
||||
"activity-dueDate": "је изменио крајњи рок на %s у предмету %s",
|
||||
@@ -162,8 +162,8 @@
|
||||
"added": "додао",
|
||||
"addMemberPopup-title": "Пријем сарадника",
|
||||
"memberPopup-title": "Избор сарадника",
|
||||
"admin": "Управник",
|
||||
"admin-desc": "Има увид и пун приступ у све предмете из ових списа. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
|
||||
"admin": "Водећи сарадник",
|
||||
"admin-desc": "Има пун увид у све предмете из ових списа. Уредник је како садржаја списа тако и делова садржаја предмета. У овим списима може да бира сараднике, поставља правила рада и чита записник.",
|
||||
"admin-announcement": "Јавни разглас",
|
||||
"admin-announcement-active": "Пусти на јавни разглас",
|
||||
"admin-announcement-title": "Јавно саопштење управника:",
|
||||
@@ -177,7 +177,7 @@
|
||||
"app-try-reconnect": "Покушавам да се поново повежем.",
|
||||
"archive": "Спакуј у архиву",
|
||||
"archive-all": "Спакуј све у архиву",
|
||||
"archive-board": "Спакуј у средишњу архиву",
|
||||
"archive-board": "Спакуј у архиву",
|
||||
"archive-board-confirm": "Да ли сте сигурни да желите да спакујете ове списе у архив?",
|
||||
"archive-card": "Спакуј предмет у архиву",
|
||||
"archive-list": "Спакуј овај део у архиву",
|
||||
@@ -447,19 +447,19 @@
|
||||
"comments": "Расправа",
|
||||
"comment": "Изнеси мишљење",
|
||||
"comment-placeholder": "",
|
||||
"comment-only": "Стручни саветник",
|
||||
"comment-only-desc": "Има право увида у све предмете и може да учествује у свим расправама.",
|
||||
"comment-assigned-only": "Спољни стручни саветник",
|
||||
"comment-assigned-only-desc": "Једино може да има право увида и узме учешће у расправама у додељеним му предметима.",
|
||||
"comment-only": "Виши саветник",
|
||||
"comment-only-desc": "Има право увида у садржај свих предмета. Може да учествује у расправама.",
|
||||
"comment-assigned-only": "Саветник",
|
||||
"comment-assigned-only-desc": "Има право увида у садржај само оних предмета за које је задужен. Може да учествује у расправама.",
|
||||
"comment-delete": "Да ли сте сигурни да желите да повучете изнешено мишљење?",
|
||||
"deleteCommentPopup-title": "Повлачите мишљење?",
|
||||
"no-comments": "Посматрач",
|
||||
"no-comments-desc": "Без права на расправу и права на увид у записник",
|
||||
"read-only": "Читалац",
|
||||
"read-only-desc": "Има право увида у све предмете.",
|
||||
"read-assigned-only": "Спољни читалац",
|
||||
"read-assigned-only-desc": "Има право увида само у додељене предмете.",
|
||||
"worker": "Приправник",
|
||||
"no-comments": "Приправник",
|
||||
"no-comments-desc": "Има право увида у садржај свих предмета. Не може да учествује у расправи. Може да мења садржај списа.",
|
||||
"read-only": "Виши посматрач",
|
||||
"read-only-desc": "Има право увида у садржај свих предмета укључујући и ток расправе али нема право учешћа у расправи. Не може да мења садржај списа.",
|
||||
"read-assigned-only": "Посматрач",
|
||||
"read-assigned-only-desc": "Виши посматрач коме је једино одузето право да уопште има увид у предмете за које није задужен",
|
||||
"worker": "Помоћник",
|
||||
"worker-desc": "Може да ради помоћне послове - да премешта предмете, бира оне које ће пратити и да учествује у расправи. ",
|
||||
"computer": "Рачунар",
|
||||
"confirm-subtask-delete-popup": "Да ли сте сигурни да желите да избришете овај издвојени посао?",
|
||||
@@ -547,8 +547,8 @@
|
||||
"enable-vertical-scrollbars": "Омогући вертикалне клизаче",
|
||||
"enable-wip-limit": "Уведи ограничење",
|
||||
"error-board-doesNotExist": "Овакви списи не постоје",
|
||||
"error-board-notAdmin": "Да би то урадили, треба да будете управник ових списа",
|
||||
"error-board-notAMember": "Да би то урадили треба да будете сарадник на овим списима",
|
||||
"error-board-notAdmin": "Да би то урадили, треба да постанете водећи сарадник ових списа",
|
||||
"error-board-notAMember": "Да би то урадили за почетак треба да постанете сарадник на овим списима",
|
||||
"error-notAllowed": "Ваша улога на табли не дозвољава то",
|
||||
"error-json-malformed": "То што сте укуцали нема исправан JSON облик",
|
||||
"error-json-schema": "Ваши подаци у JSON облику не укључују исправне информације у предвиђеном облику",
|
||||
@@ -719,7 +719,7 @@
|
||||
"label-delete-pop": "Пажња. Опозив ове радње неће бити могућ! Изабрана налепница ће бити одлепљена/уклоњена из свих предмета и њена историја ће исчезнути.",
|
||||
"labels": "Налепнице",
|
||||
"language": "Језик",
|
||||
"last-admin-desc": "Не можете да мењате задужења јер мора постојати барем један управник/администратор.",
|
||||
"last-admin-desc": "Не можете да промените улогу јер мора преостати барем један водећи сарадник.",
|
||||
"leave-board": "Раздужи све предмете",
|
||||
"leave-board-pop": "Да ли сте сигурни да желите да раздужите све предмете из списа __boardTitle__? Бићете изузети из свих тих предмета.",
|
||||
"leaveBoardPopup-title": "Раздуживање",
|
||||
@@ -733,7 +733,7 @@
|
||||
"settingsUserPopup-title": "Рад са сарадницима",
|
||||
"settingsTeamPopup-title": "Рад са правним тимовима",
|
||||
"settingsOrgPopup-title": "Рад са странкама",
|
||||
"orgAdminsPopup-title": "Администратори организације",
|
||||
"orgAdminsPopup-title": "Одговорна лица странке",
|
||||
"swimlaneActionPopup-title": "Радње у овом току поступка",
|
||||
"swimlaneAddPopup-title": "Додаје испод врсту поступка",
|
||||
"listImportCardPopup-title": "Унеси једнан Trello предмет",
|
||||
@@ -764,8 +764,8 @@
|
||||
"multi-selection": "Обухват",
|
||||
"multi-selection-label": "Употреби налепницу",
|
||||
"multi-selection-member": "Задужи сарадника",
|
||||
"multi-selection-on": "У току је обухват",
|
||||
"multi-selection-off": "Искључи вишеструки избор",
|
||||
"multi-selection-on": "Обухватам...",
|
||||
"multi-selection-off": "Прекини обухват",
|
||||
"muted": "Не примај обавештења",
|
||||
"muted-info": "Нећете чути обавештења кад наступе било какве промене у овим списима",
|
||||
"my-boards": "Списи у мојој надлежности",
|
||||
@@ -775,9 +775,9 @@
|
||||
"no-archived-swimlanes": "Нема архивираних врсти поступака.",
|
||||
"no-results": "Нема резултата",
|
||||
"normal": "Виши сарадник",
|
||||
"normal-desc": "Може да има и увид и пун приступ у све предмете из ових списа. Не може да поставља правила рада у овим списима.",
|
||||
"normal-assigned-only": "Спољни виши сарадник",
|
||||
"normal-assigned-only-desc": "Има увид и пуна права само у оним предметима где има надлежност или је опуномоћен.",
|
||||
"normal-desc": "Има потпуно иста права као и водећи сарадник уз разлику да не може да поставља правила рада у овим списима.",
|
||||
"normal-assigned-only": "Сарадник",
|
||||
"normal-assigned-only-desc": "Има потпуно иста права као и виши сарадник уз разлику да је сараднику одузето право да уопште има увид у предмете за које није задужен.",
|
||||
"not-accepted-yet": "Позив још увек није прихваћен",
|
||||
"notify-participate": "Примајте допунске извештаје при било којој измени предмета које сте сами завели или где сте сарадник",
|
||||
"notify-watch": "Примајте допунске извештаје при било којој измени списа, делова поступака или предмета које пратите",
|
||||
@@ -1000,9 +1000,9 @@
|
||||
"org-domains": "Домени ове организације",
|
||||
"org-domains-description": "Имена домаћина на којима се послужује ова организација, раздвојена зарезима — на пример a.example.com, kanban.example.org. Захтев ка било ком од њих јесте захтев ове организације, а визуелни идентитет испод замењује идентитет инстанце. Важи само ако је сервер покренут са MULTITENANCY=true. Оставите празно ако ова организација није закупац.",
|
||||
"error-org-domain-taken": "Тај домен већ припада другој организацији:",
|
||||
"org-admins": "Администратори организације",
|
||||
"org-admins-description": "Чланови ове организације који смеју да је администрирају: њене људе и њене резервне копије, и ништа друго. Никада не могу да доделе овлашћење администратора целог сајта нити да управљају администратором сајта.",
|
||||
"org-admin": "Администратор организације",
|
||||
"org-admins": "Одговорна лица странке",
|
||||
"org-admins-description": "Постављени појединци који сами могу да праве сарадничку мрежу странке и њен резервни примерак података. Нису део одлучивања око управе.",
|
||||
"org-admin": "Одговорно лице",
|
||||
"team-shared-templates": "Дељени шаблони",
|
||||
"team-propagate-members-to-boards": "Пренеси чланове на табле",
|
||||
"team-sync-members-from-auth": "Синхронизуј чланове са провајдера аутентификације",
|
||||
@@ -1033,10 +1033,10 @@
|
||||
"default": "Подразумевано",
|
||||
"defaultdefault": "Подразумевано",
|
||||
"queue": "Пријемно одељење",
|
||||
"subtask-settings": "Поставка издвојених послова",
|
||||
"subtask-settings": "Издвојени послови",
|
||||
"card-settings": "Садржај предмета",
|
||||
"minicard-settings": "Рад са омотима предмета",
|
||||
"boardSubtaskSettingsPopup-title": "Руковање издвојеним пословима",
|
||||
"boardSubtaskSettingsPopup-title": "Поставка издвојених послова",
|
||||
"boardCardSettingsPopup-title": "Делови садржаја",
|
||||
"boardMinicardSettingsPopup-title": "Рад са омотима предмета",
|
||||
"deposit-subtasks-board": "Депонуј издвојене послове у списе:",
|
||||
@@ -1283,7 +1283,7 @@
|
||||
"display-authentication-method": "Прикажи метод за утврђивање идентитета",
|
||||
"oidc-button-text": "Измени OIDC текст на дугмету",
|
||||
"default-authentication-method": "Уобичајени метод за утврђивање идентитета",
|
||||
"duplicate-board": "Умножи списе у још један примерак",
|
||||
"duplicate-board": "Умножи један примерак",
|
||||
"duplicate-board-confirm": "Да ли сте сигурни да желите да направите још један примерак списа?",
|
||||
"org-number": "Број евидентираних странака је:",
|
||||
"team-number": "Број правних тимова је:",
|
||||
@@ -1352,21 +1352,21 @@
|
||||
"allow-rename": "Дозволи преименовање",
|
||||
"allowRenamePopup-title": "Дозволи преименовање",
|
||||
"roles": "Улоге",
|
||||
"roles-info": "Изаберите које улоге на табли смеју да изврше сваку радњу. Корисници администраторског панела (глобални администратори) увек имају сва права и не могу се овде ограничити.",
|
||||
"allow-invite-to-board": "Дозволи позивање на таблу",
|
||||
"all-board-members": "Сви чланови табле",
|
||||
"board-admin": "Администратор табле",
|
||||
"roles-status": "Стање улога",
|
||||
"roles-status-desc": "Шта свака улога табле сме. Само за читање: прати поља за потврду изнад, па приказује избор пре чувања.",
|
||||
"roles-info": "Одредите она лица односно оне улоге којима се дозвољава вршење наведене радње. Управници свакако имају неограничена права која им се овде не могу ограничити.",
|
||||
"allow-invite-to-board": "Ко има дозволу да буде позивар",
|
||||
"all-board-members": "Било који сарадник",
|
||||
"board-admin": "Водећи сарадник",
|
||||
"roles-status": "Опис улога",
|
||||
"roles-status-desc": "Описује шта свака улога значи. Исписане вредности се не могу мењати - оне вам само помажу при избору.",
|
||||
"roles-status-role": "Улога",
|
||||
"roles-status-invite": "Позив на таблу",
|
||||
"roles-status-sees": "Види картице",
|
||||
"roles-status-comment": "Изнеси мишљење",
|
||||
"roles-status-write": "Прављење и уређивање",
|
||||
"roles-status-manage": "Подешавања табле",
|
||||
"roles-status-invite": "Позива",
|
||||
"roles-status-sees": "Има увид у предмете",
|
||||
"roles-status-comment": "Учествује у расправи",
|
||||
"roles-status-write": "Мења садржај",
|
||||
"roles-status-manage": "Ради поставку списа",
|
||||
"roles-status-sees-all": "Све",
|
||||
"roles-status-sees-assigned": "Само додељене",
|
||||
"roles-status-empty": "Нема улога табле.",
|
||||
"roles-status-empty": "Нису подељене улоге",
|
||||
"start-day-of-week": "Постави који дан се рачуна за почетак недеље",
|
||||
"monday": "Понедељак",
|
||||
"tuesday": "Уторак",
|
||||
@@ -1836,7 +1836,7 @@
|
||||
"text": "Изворна реченица",
|
||||
"translation-text": "Превод",
|
||||
"show-subtasks-field": "Прикажи поље издвојени послови",
|
||||
"show-week-of-year": "Прикажи редни број недељу у години",
|
||||
"show-week-of-year": "Прикажи редни број недеље у години",
|
||||
"convert-to-markdown": "Претвори у структуирани текст",
|
||||
"import-board-zip": "Додај .zip архиву која има списе у json облику и фасцикле по имену списа где је предметна грађа",
|
||||
"collapse": "Скупи",
|
||||
@@ -2154,7 +2154,7 @@
|
||||
"migration-successful": "Обнова је успешно окончана",
|
||||
"migration-failed": "Обнова није била успешна",
|
||||
"migrations": "Радионица",
|
||||
"migrations-admin-only": "За обнову ових списа надлежан је једино онај ко је њихов управник",
|
||||
"migrations-admin-only": "За обнову ових списа надлежан је једино онај ко је њихов водећи сарадник",
|
||||
"migrations-description": "Овде може да се изврши провера целовитости података и поправка оштећења. Сваки поступак обнове се може спровести независно један од другог",
|
||||
"no-issues-found": "Нису уочена оштећења",
|
||||
"run-migration": "Покрени опоравак",
|
||||
|
||||
+33
-9
@@ -4,6 +4,7 @@ import { check, Match } from 'meteor/check';
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
|
||||
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
|
||||
import escapeForRegex from 'escape-string-regexp';
|
||||
import CustomFields from './customFields';
|
||||
import {
|
||||
@@ -1862,7 +1863,29 @@ Boards.helpers({
|
||||
// Upsert keyed on the deterministic _id: concurrent self-heals collide on
|
||||
// the _id unique index, so only one insert wins instead of racing the
|
||||
// check-then-insert and each inserting a new swimlane.
|
||||
Swimlanes.upsert({ _id: defaultId }, { $setOnInsert: this.defaultSwimlaneFields() });
|
||||
//
|
||||
// It must be the ASYNC upsert. Meteor 3 removed the synchronous one on the
|
||||
// server, and calling it threw
|
||||
// Error: update is not available on the server. Please use updateAsync()
|
||||
// out of every `moveSwimlane` that reached this self-heal - the failure an
|
||||
// admin kept seeing in Admin Panel / Problems / Database problems. This
|
||||
// getter is SYNCHRONOUS and cannot await, so the upsert is started and not
|
||||
// waited for: the self-heal still happens, and this call returns whatever
|
||||
// is in the cache, which the caller already had to handle (it was undefined
|
||||
// before the self-heal existed too). A caller that needs the swimlane back
|
||||
// in the same tick uses ensureDefaultSwimlaneIdAsync().
|
||||
try {
|
||||
const p = Swimlanes.upsertAsync(
|
||||
{ _id: defaultId },
|
||||
{ $setOnInsert: this.defaultSwimlaneFields() },
|
||||
);
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('default swimlane self-heal failed:', e && e.message);
|
||||
}
|
||||
}
|
||||
return ReactiveCache.getSwimlane({ _id: defaultId });
|
||||
},
|
||||
|
||||
@@ -2452,14 +2475,15 @@ Boards.userBoards = (
|
||||
if (selector.title === undefined) {
|
||||
selector.title = notHelperBoardTitle();
|
||||
}
|
||||
selector.$or = [
|
||||
...(includePublic ? [{ permission: 'public' }] : []),
|
||||
{ members: { $elemMatch: { userId, isActive: true } } },
|
||||
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
|
||||
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
|
||||
// #5850: domain-based board sharing — board shared with the user's email domain.
|
||||
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
|
||||
];
|
||||
// GHSA-gwc4-fw7p-gw58: the same builder the `board` publication uses, so
|
||||
// "which boards may this user see" has one answer and cannot drift.
|
||||
selector.$or = boardVisibilitySelectors({
|
||||
userId,
|
||||
orgIds: user.orgIds(),
|
||||
teamIds: user.teamIds(),
|
||||
emailDomains: user.emailDomains(),
|
||||
includePublic,
|
||||
});
|
||||
return selector;
|
||||
};
|
||||
|
||||
|
||||
+56
-26
@@ -17,6 +17,23 @@ function sanitizeText(text) {
|
||||
previous = sanitized;
|
||||
sanitized = sanitized.replace(/<[^>]*>/g, '');
|
||||
} while (sanitized !== previous);
|
||||
|
||||
// A comment losing a `<b>` is an everyday thing and says nothing. A comment
|
||||
// losing a SCRIPT TAG, an event handler or a javascript: URI is a stored-XSS
|
||||
// attempt that this line defused, and an admin should be told who wrote it
|
||||
// (docs/Security/Remediation/WeKan.md §12.6). The comment is stored sanitized
|
||||
// either way; the author is told nothing.
|
||||
if (Meteor.isServer && sanitized !== text) {
|
||||
try {
|
||||
const { removedActiveMarkup, CANARY_IDS } = require('/models/lib/injectionDetect');
|
||||
if (removedActiveMarkup(text, sanitized)) {
|
||||
require('/server/lib/canary').tripCanary(CANARY_IDS.text, {
|
||||
detail: 'active markup removed from a comment',
|
||||
});
|
||||
}
|
||||
} catch (e) { /* a canary must never break a comment */ }
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -201,34 +218,47 @@ CardComments.helpers({
|
||||
|
||||
CardComments.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Server-side enforcement of comment edit/delete permissions (issue #5906).
|
||||
// Server-side enforcement of comment edit/delete permissions (issue #5906).
|
||||
//
|
||||
// The DDP `allow` rule in server/permissions/cardComments.js is the first gate,
|
||||
// but the per-board `restrictCommentEditing` setting is enforced here so the
|
||||
// rule cannot be bypassed and the decision lives next to the data. Exported so
|
||||
// the REST handlers can enforce the SAME rule (GHSA-pqr4-rxgp-hv2m) rather than
|
||||
// relying on collection hooks, which cannot see an HTTP caller's identity.
|
||||
export async function assertCanMutateComment(userId, doc) {
|
||||
// Server-internal operations (board copy, cleanup, migrations, etc.) run
|
||||
// without an authenticated user; do not block those here. User-initiated
|
||||
// DDP calls always carry a userId and are still gated by the allow rule.
|
||||
//
|
||||
// The DDP `allow` rule in server/permissions/cardComments.js is the first
|
||||
// gate, but the per-board `restrictCommentEditing` setting is enforced here
|
||||
// so the rule cannot be bypassed and the decision lives next to the data.
|
||||
const assertCanMutateComment = async (userId, doc) => {
|
||||
// Server-internal operations (board copy, cleanup, migrations, etc.) run
|
||||
// without an authenticated user; do not block those here. User-initiated
|
||||
// DDP calls always carry a userId and are still gated by the allow rule.
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const isAuthor = userId === doc.userId;
|
||||
if (isAuthor) {
|
||||
return; // Authors may always edit/delete their own comments.
|
||||
}
|
||||
const board = await Boards.findOneAsync(doc.boardId);
|
||||
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
|
||||
throw new Meteor.Error(
|
||||
'error-comment-edit-not-allowed',
|
||||
"You are not allowed to edit or delete another user's comment on this board.",
|
||||
);
|
||||
}
|
||||
};
|
||||
// GHSA-pqr4-rxgp-hv2m: an HTTP handler is NOT a server-internal operation,
|
||||
// but it reached the collection with no Meteor userId in the invocation
|
||||
// context, so it landed on this trusted path — and every board member could
|
||||
// delete anyone's comment through the REST API, restrictCommentEditing or
|
||||
// not. The REST handlers now call this function themselves, with req.userId,
|
||||
// BEFORE touching the collection (server/models/cardComments.js). The trusted
|
||||
// path stays for the genuine internal callers it was written for.
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const isAuthor = userId === doc.userId;
|
||||
if (isAuthor) {
|
||||
return; // Authors may always edit/delete their own comments.
|
||||
}
|
||||
const board = await Boards.findOneAsync(doc.boardId);
|
||||
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
|
||||
const error = new Meteor.Error(
|
||||
'error-comment-edit-not-allowed',
|
||||
"You are not allowed to edit or delete another user's comment on this board.",
|
||||
);
|
||||
// So the REST layer answers 403 rather than 500 (httpStatusForError).
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
CardComments.before.update(async (userId, doc) => {
|
||||
await assertCanMutateComment(userId, doc);
|
||||
});
|
||||
|
||||
+17
-10
@@ -2614,17 +2614,24 @@ Cards.helpers({
|
||||
|
||||
// #3392: card-to-card dependencies ("Red Strings") only connect cards on
|
||||
// the same board. When a card moves to another board, drop its now
|
||||
// cross-board dependencies and remove inbound references to it from the
|
||||
// cards left behind on the old board, so no dangling lines remain.
|
||||
// cross-board dependencies. This half is part of the by-_id update below,
|
||||
// so it is allowed from the client.
|
||||
//
|
||||
// The OTHER half - removing inbound references to this card from the cards
|
||||
// left behind on the old board - needs a multi-document update with a
|
||||
// compound selector, and it used to be done right here. This helper is
|
||||
// called straight from client code (list.js, swimlanes.js, globalSearch.js,
|
||||
// sidebarFilters.js, minicard.js), and Meteor's insecure-write rule only
|
||||
// lets untrusted code updateAsync BY ID: every cross-board move therefore
|
||||
// threw "Not permitted. Untrusted code may only updateAsync documents by
|
||||
// ID" and never reached the move itself (#6572). addDependency,
|
||||
// setDependencyProps and removeDependency above already follow that rule -
|
||||
// this was the one place that did not.
|
||||
//
|
||||
// It is a Cards.after.update hook in server/models/cards.js now, where a
|
||||
// selector is allowed, and being server-side it also covers the REST API
|
||||
// and import paths, which never ran this helper at all.
|
||||
mutatedFields.cardDependencies = [];
|
||||
await Cards.updateAsync(
|
||||
{
|
||||
boardId: previousState.boardId,
|
||||
'cardDependencies.cardId': this._id,
|
||||
},
|
||||
{ $pull: { cardDependencies: { cardId: this._id } } },
|
||||
{ multi: true },
|
||||
);
|
||||
}
|
||||
|
||||
await Cards.updateAsync(this._id, { $set: mutatedFields });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { isEmptyObject } from 'jquery';
|
||||
import Activities from '/models/activities';
|
||||
import Boards from './boards';
|
||||
import Cards from '/models/cards';
|
||||
|
||||
+22
-1
@@ -31,6 +31,19 @@ EventLog.attachSchema(
|
||||
source: { type: String, optional: true }, // guard/module/test name (wekan… or sqlite…/ferretdb…)
|
||||
cwe: { type: String, optional: true },
|
||||
userId: { type: String, optional: true },
|
||||
// WHO and FROM WHERE. A security event that says only "something was
|
||||
// blocked" cannot be acted on: the admin needs the account and the address
|
||||
// to decide whether to lock it, and to recognise the same actor across
|
||||
// several events. Denormalised at write time on purpose - the username is
|
||||
// what the account was CALLED when it tried, which a later rename must not
|
||||
// rewrite, and a deleted account must not erase.
|
||||
username: { type: String, optional: true },
|
||||
ip: { type: String, optional: true },
|
||||
// How many attempts this one row stands for. A canary sits on a path an
|
||||
// attacker controls in a loop, so repeats inside a window are counted rather
|
||||
// than each written (models/lib/canaryTokens.js): 1 for an ordinary event, N
|
||||
// for a summary flush.
|
||||
count: { type: Number, optional: true },
|
||||
detail: { type: String, optional: true },
|
||||
// The 'database' stream's own four fields (server/lib/databaseProblems.js).
|
||||
// They MUST be declared here: collection2 cleans every insert against this
|
||||
@@ -62,7 +75,10 @@ EventLogAcks.attachSchema(
|
||||
// (MongoDB, or FerretDB over SQLite/PostgreSQL/MySQL/MariaDB/SAP HANA), what the
|
||||
// message means, and what an admin should do about it. See
|
||||
// models/lib/databaseErrors.js and server/lib/databaseProblems.js.
|
||||
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database'];
|
||||
// 'integrity' is what the FILESYSTEM said: a stored file that is not the file
|
||||
// WeKan stored (server/lib/fileIntegrityScan.js), and whether this server
|
||||
// stopped cleanly last time (server/lib/uptimeWatch.js).
|
||||
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database', 'integrity'];
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// The Security/Speed/Tests report pages filter by `stream` and sort by `at`
|
||||
@@ -97,6 +113,11 @@ if (Meteor.isServer) {
|
||||
selector.$or = [
|
||||
{ category: rx }, { bleed: rx }, { source: rx }, { detail: rx },
|
||||
{ db: rx }, { kind: rx }, { type: rx }, { message: rx },
|
||||
// WHO and FROM WHERE are the two things an admin looking at a security
|
||||
// event actually wants to pivot on: every other event from this address,
|
||||
// every other event from this account. Searching the table for either
|
||||
// has to find the rows that DISPLAY it, so both columns are searched.
|
||||
{ username: rx }, { ip: rx },
|
||||
];
|
||||
}
|
||||
return selector;
|
||||
|
||||
+59
-4
@@ -61,6 +61,29 @@ export function writeWithBackpressure(res, str) {
|
||||
});
|
||||
}
|
||||
|
||||
// GHSA-4mxf-m8pq-xc9p: an avatar's `versions.original.path` comes out of the
|
||||
// DATABASE, and the export below reads it off disk and embeds the bytes as
|
||||
// base64. The avatar allow rule now refuses to let a client write that field
|
||||
// (server/permissions/avatars.js), and this is the second lock: a path is only
|
||||
// read when it resolves to somewhere inside WeKan's own storage, so a path
|
||||
// poisoned any other way - an old document written before the fix, a restored
|
||||
// backup, a bad migration - still cannot turn an export into a file read.
|
||||
//
|
||||
// The roots are computed the same way models/avatars.js and models/attachments.js
|
||||
// compute their storagePath, via the shared helper both of them are kept in sync
|
||||
// with.
|
||||
export function avatarStorageRoots() {
|
||||
const { computeStoragePaths } = require('./lib/attachmentStoragePath');
|
||||
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
|
||||
return [paths.avatars, paths.attachments];
|
||||
}
|
||||
|
||||
// True when `storedPath` may be read. Exported so the guard itself is testable.
|
||||
export function isReadableStoredFilePath(storedPath, roots) {
|
||||
const { isPathInsideAny } = require('./lib/storagePathContainment');
|
||||
return isPathInsideAny(storedPath, roots || avatarStorageRoots());
|
||||
}
|
||||
|
||||
// exporter maybe is broken since Gridfs introduced, add fs and path
|
||||
export class Exporter {
|
||||
constructor(boardId, attachmentId, options = {}) {
|
||||
@@ -117,12 +140,33 @@ export class Exporter {
|
||||
buffer.fill(0);
|
||||
|
||||
// callback has the form function (err, res) {}
|
||||
|
||||
// GHSA-4mxf-m8pq-xc9p: this is the ONE place the export turns a stored
|
||||
// path into bytes, for attachments and avatars alike, so the containment
|
||||
// check belongs here as well as at the call sites. A path that does not
|
||||
// resolve inside WeKan's storage is not a file this export may read.
|
||||
const storedPath = doc?.versions?.original?.path;
|
||||
if (!isReadableStoredFilePath(storedPath)) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Refused to export a file stored outside the storage root:', storedPath);
|
||||
}
|
||||
// A canary, not a guess: every path WeKan writes is inside the storage
|
||||
// root, so one that is not was put there by something that should not
|
||||
// have. The export continues without the file, exactly as before.
|
||||
try {
|
||||
const { tripCanary } = require('/server/lib/canary');
|
||||
tripCanary('export.path-outside-storage', { detail: 'during board export' });
|
||||
} catch (e) { /* never break an export to report on it */ }
|
||||
callback(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpFile = path.join(
|
||||
os.tmpdir(),
|
||||
`tmpexport${process.pid}${Math.random()}`,
|
||||
);
|
||||
const tmpWriteable = fs.createWriteStream(tmpFile);
|
||||
const readStream = fs.createReadStream(doc.versions.original.path);
|
||||
const readStream = fs.createReadStream(storedPath);
|
||||
readStream.on('data', function (chunk) {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
});
|
||||
@@ -295,7 +339,10 @@ export class Exporter {
|
||||
const m = localUrl.match(/\/(?:cdn\/storage\/avatars|cfs\/files\/avatars)\/([^/?#]+)/);
|
||||
if (m && m[1]) {
|
||||
const avatar = await ReactiveCache.getAvatar(m[1]);
|
||||
if (avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path) {
|
||||
const avatarPath = avatar?.versions?.original?.path;
|
||||
// GHSA-4mxf-m8pq-xc9p: only read it when it is really inside WeKan's
|
||||
// own storage. A path pointing anywhere else is not an avatar.
|
||||
if (avatarPath && isReadableStoredFilePath(avatarPath)) {
|
||||
const file = await getBase64DataAsync(avatar);
|
||||
if (file) {
|
||||
user.profile = user.profile || {};
|
||||
@@ -434,7 +481,13 @@ export class Exporter {
|
||||
if (!this._excludeAttachments) {
|
||||
await w(',"file":"');
|
||||
const filePath = att.versions && att.versions.original && att.versions.original.path;
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
// GHSA-4mxf-m8pq-xc9p class: the ATTACHMENT half of the same read. The
|
||||
// attachment allow rule refuses a client-supplied path, so this is not
|
||||
// the reported hole - but a path is only as trustworthy as every way it
|
||||
// could have been written, and this streaming export is the one
|
||||
// remaining place that read a stored path with nothing but an
|
||||
// existsSync. Same containment check as the avatars above.
|
||||
if (filePath && isReadableStoredFilePath(filePath) && fs.existsSync(filePath)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const rs = fs.createReadStream(filePath);
|
||||
let leftover = Buffer.alloc(0);
|
||||
@@ -512,7 +565,9 @@ export class Exporter {
|
||||
if (m && m[1]) {
|
||||
const avatar = await avatarsRaw.findOne({ _id: m[1] });
|
||||
const p = avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path;
|
||||
if (p) {
|
||||
// GHSA-4mxf-m8pq-xc9p: same containment check as the non-streaming
|
||||
// export above - never read a stored path from outside the storage.
|
||||
if (p && isReadableStoredFilePath(p)) {
|
||||
try {
|
||||
const buf = fs.readFileSync(p);
|
||||
user.profile = user.profile || {};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
// ============================================================================
|
||||
// The filesystem storage BASELINE (design: docs/Security/Remediation/WeKan.md §13).
|
||||
//
|
||||
// One document per stored file: what it was called, how big it was, when it was
|
||||
// last modified, its md5/sha256/sha512, and an ed25519 signature over all of
|
||||
// that. server/lib/fileIntegrityScan.js writes it and re-checks it once a day.
|
||||
//
|
||||
// In the EXISTING WeKan database, like every other part of this subsystem - no
|
||||
// new files under WRITABLE_PATH, nothing extra to back up or to forget to back
|
||||
// up, and it works the same on FerretDB and MongoDB.
|
||||
//
|
||||
// Deliberately NOT schema-attached: `digests` is a small open map, and the point
|
||||
// of this collection is to be a faithful record of what was observed rather than
|
||||
// something a schema might quietly clean fields out of - which is exactly the
|
||||
// bug that once emptied the database-problems rows (models/eventLog.js).
|
||||
// ============================================================================
|
||||
|
||||
const FileIntegrity = new Mongo.Collection('fileIntegrity');
|
||||
|
||||
// The signing key, and the scan's own bookkeeping (when it last ran). Separate
|
||||
// from the baseline so a full re-baseline never touches the key.
|
||||
export const IntegrityKeys = new Mongo.Collection('fileIntegrityKeys');
|
||||
|
||||
if (Meteor.isServer) {
|
||||
const { ensureIndex } = require('/server/lib/mongoStartup');
|
||||
Meteor.startup(async () => {
|
||||
// The scan reads by _id (the path) and sweeps by `at`.
|
||||
await ensureIndex(FileIntegrity, { at: -1 });
|
||||
});
|
||||
|
||||
// NEVER published. The baseline is a map of every file on the server and the
|
||||
// key document is a private key; neither has any business on a client, not
|
||||
// even an admin's. Admin Panel reads the FINDINGS, which are ordinary event
|
||||
// log rows, not this.
|
||||
FileIntegrity.deny({
|
||||
insert: () => true,
|
||||
update: () => true,
|
||||
remove: () => true,
|
||||
});
|
||||
IntegrityKeys.deny({
|
||||
insert: () => true,
|
||||
update: () => true,
|
||||
remove: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
export default FileIntegrity;
|
||||
@@ -101,6 +101,7 @@ const ADMIN_PAGES = {
|
||||
cards: 'report-cards',
|
||||
recovery: 'report-recovery',
|
||||
database: 'report-database',
|
||||
integrity: 'report-integrity',
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
@@ -173,6 +174,7 @@ const ADMIN_PANE_TITLES = {
|
||||
cards: { titleKey: 'cardsReportTitle' },
|
||||
recovery: { titleKey: 'recoveryReportTitle' },
|
||||
database: { titleKey: 'databaseReportTitle' },
|
||||
integrity: { titleKey: 'integrityReportTitle' },
|
||||
},
|
||||
attachments: {
|
||||
backup: { titleKey: 'backup' },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pure, dependency-free construction of the Mongo `$or` that answers "which
|
||||
// boards may this user see". No Meteor imports, so it is unit tested directly
|
||||
// with plain Node (tests/boardVisibilitySelectors.test.cjs).
|
||||
//
|
||||
// GHSA-gwc4-fw7p-gw58: this was written out TWICE, and the two copies disagreed.
|
||||
// Boards.userBoards() - the All Boards list - matched org/team/domain shares
|
||||
// with `$elemMatch: { ..., isActive: true }`, but the `board` publication (the
|
||||
// one that sends the board and all its cards) matched them with a dotted
|
||||
// `'orgs.orgId': { $in: [...] }`, which ignores isActive entirely. isActive is
|
||||
// the REVOKE switch: setting a share to `isActive: false` made the board vanish
|
||||
// from All Boards, as it should, while a user who still knew the boardId could
|
||||
// subscribe to it and receive the whole private board. A revoke that the primary
|
||||
// data publication does not honour is not a revoke.
|
||||
//
|
||||
// So there is one builder now, and both callers use it. A share entry counts
|
||||
// only while it is active, everywhere.
|
||||
|
||||
/**
|
||||
* @param {object} args
|
||||
* @param {string} [args.userId] the subscriber; omitted/falsy means anonymous
|
||||
* @param {string[]} [args.orgIds] organisations the user belongs to
|
||||
* @param {string[]} [args.teamIds] teams the user belongs to
|
||||
* @param {string[]} [args.emailDomains] the user's verified email domains (#5850)
|
||||
* @param {boolean} [args.includePublic=true] include `{ permission: 'public' }`,
|
||||
* which is not a relationship to the user at all but "anybody may open
|
||||
* this" - wanted in a board LIST, not in a search over all boards.
|
||||
* @return {object[]} the `$or` clauses, in a stable order
|
||||
*/
|
||||
function boardVisibilitySelectors({
|
||||
userId,
|
||||
orgIds = [],
|
||||
teamIds = [],
|
||||
emailDomains = [],
|
||||
includePublic = true,
|
||||
} = {}) {
|
||||
const selectors = [];
|
||||
|
||||
if (includePublic) {
|
||||
selectors.push({ permission: 'public' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return selectors;
|
||||
}
|
||||
|
||||
const clean = (values) =>
|
||||
Array.isArray(values) ? values.filter((v) => typeof v === 'string' && v) : [];
|
||||
|
||||
selectors.push({ members: { $elemMatch: { userId, isActive: true } } });
|
||||
selectors.push({
|
||||
orgs: { $elemMatch: { orgId: { $in: clean(orgIds) }, isActive: true } },
|
||||
});
|
||||
selectors.push({
|
||||
teams: { $elemMatch: { teamId: { $in: clean(teamIds) }, isActive: true } },
|
||||
});
|
||||
// #5850: domain-based board sharing — board shared with the user's email domain.
|
||||
selectors.push({
|
||||
domains: { $elemMatch: { domain: { $in: clean(emailDomains) }, isActive: true } },
|
||||
});
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
module.exports = { boardVisibilitySelectors };
|
||||
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Canary tokens — the pure half
|
||||
// (design: docs/Security/Remediation/WeKan.md §12)
|
||||
// ----------------------------------------------------------------------------
|
||||
// A CANARY is a tripwire placed at a point where somebody could TRY to override
|
||||
// permissions. Ordinary users never reach it: the code path only runs when a
|
||||
// request asks for something the rules forbid. So a canary trip is not noise -
|
||||
// it is somebody probing, and it is worth an admin's attention with WHO and
|
||||
// FROM WHERE attached.
|
||||
//
|
||||
// Three properties, and they are the whole point:
|
||||
//
|
||||
// 1. SILENT. Tripping a canary changes nothing the caller can observe. The
|
||||
// trip function returns `false` so a guard can `return tripCanary(...)` in
|
||||
// place of `return false`, and the attacker gets the same refusal, with the
|
||||
// same timing and the same message, as before the canary existed. Nothing
|
||||
// is ever returned, thrown, delayed or logged back to them.
|
||||
// 2. BOUNDED. A canary sits on a path an attacker controls and can hit in a
|
||||
// loop. Writing one database row per attempt is a denial-of-service the
|
||||
// attacker gets for free - CPU on every insert, and a table that grows
|
||||
// until the disk is full. So the FIRST trip in a window is recorded and the
|
||||
// rest are COUNTED, then flushed as a single summary event when the window
|
||||
// closes. The counter map itself is capped, so memory is bounded too.
|
||||
// 3. ATTRIBUTED. Every trip carries the actor - userId, username and client IP
|
||||
// address - so Admin Panel → Problems → Security answers "who tried this,
|
||||
// and from where", not just "something happened".
|
||||
//
|
||||
// This file is pure and dependency-free (no Meteor, no database, no clock of its
|
||||
// own), so it is unit tested with plain Node: tests/canaryTokens.test.cjs. The
|
||||
// runtime half - resolving the actor, applying this limiter, writing the event -
|
||||
// is server/lib/canary.js.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------- the catalog
|
||||
//
|
||||
// One entry per tripwire. `key` is the existing securityCategories key, so a
|
||||
// canary's category, *Bleed name, severity and CWE come from the SAME catalog
|
||||
// the other guards use and cannot drift from it (models/lib/securityCategories.js).
|
||||
//
|
||||
// `what` is the sentence an admin reads. It says what was ATTEMPTED, in the
|
||||
// terms of the feature, because "authz.board blocked" is not something anyone
|
||||
// can act on.
|
||||
const CANARIES = {
|
||||
'card.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a card into a board they cannot write to',
|
||||
},
|
||||
'card.invisible-parent': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to set a card parent on a board they cannot see',
|
||||
},
|
||||
'list.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a list into a board they cannot write to',
|
||||
},
|
||||
'swimlane.cross-board-move': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to move a swimlane into a board they cannot write to',
|
||||
},
|
||||
'checklist.cross-board-move': {
|
||||
key: 'authz.checklist',
|
||||
what: 'tried to move a checklist into a board they cannot write to',
|
||||
},
|
||||
'checklist-item.cross-board-move': {
|
||||
key: 'authz.checklist',
|
||||
what: 'tried to move a checklist item into a board they cannot write to',
|
||||
},
|
||||
'attachment.version-path': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write the on-disk path of an attachment',
|
||||
},
|
||||
'avatar.version-path': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write the on-disk path of an avatar',
|
||||
},
|
||||
'attachment.restricted-field': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write a restricted attachment field',
|
||||
},
|
||||
'avatar.restricted-field': {
|
||||
key: 'authz.file-path',
|
||||
what: 'tried to write a restricted avatar field',
|
||||
},
|
||||
'avatar.not-owner': {
|
||||
key: 'authz.file-path',
|
||||
what: "tried to change another user's avatar",
|
||||
},
|
||||
'comment.foreign-delete': {
|
||||
key: 'authz.comment',
|
||||
what: "tried to delete another user's comment over the REST API",
|
||||
},
|
||||
'reaction.foreign': {
|
||||
key: 'authz.comment',
|
||||
what: "tried to add or remove another user's comment reaction",
|
||||
},
|
||||
'export.path-outside-storage': {
|
||||
key: 'authz.file-path',
|
||||
what: 'a stored file path pointed outside the storage root and was not read',
|
||||
},
|
||||
'card.vote-field': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to write a vote field directly instead of through the method',
|
||||
},
|
||||
'card.poker-field': {
|
||||
key: 'authz.board',
|
||||
what: 'tried to write a poker field directly instead of through the method',
|
||||
},
|
||||
'database.canary': {
|
||||
key: 'authz.database',
|
||||
what: 'the database refused an operation that WeKan never issues',
|
||||
},
|
||||
|
||||
// --- Injection ------------------------------------------------------------
|
||||
// WeKan builds no SQL, so a SQL canary can only come from the database's own
|
||||
// last-look guard (docs/Security/Remediation/FerretDB.md §3b). NoSQL is the
|
||||
// one WeKan can see for itself: a selector that carries an execution
|
||||
// operator, or an operator object where the value a user typed belongs.
|
||||
'injection.nosql-selector': {
|
||||
key: 'injection.nosql',
|
||||
what: 'sent a query carrying a database-side execution operator',
|
||||
},
|
||||
'injection.nosql-operator': {
|
||||
key: 'injection.nosql',
|
||||
what: 'sent a query operator where a plain value was expected',
|
||||
},
|
||||
'injection.sql-statement': {
|
||||
key: 'injection.sql',
|
||||
what: 'the database refused a statement that looked like injected SQL',
|
||||
},
|
||||
|
||||
// --- Sanitization ---------------------------------------------------------
|
||||
// Sanitizing is routine - a filename gets trimmed, a document gets its
|
||||
// formatting normalised - and routine is not worth an admin's attention. What
|
||||
// IS worth it is sanitization that removed something DANGEROUS: markup that
|
||||
// would have executed, a path that would have escaped its directory, a type
|
||||
// that lied about itself. Only those trip.
|
||||
'sanitize.dangerous-filename': {
|
||||
key: 'file.name',
|
||||
what: 'uploaded a filename carrying an exploit pattern',
|
||||
},
|
||||
'sanitize.path-traversal': {
|
||||
key: 'file.name',
|
||||
what: 'used a file path that tried to escape its directory',
|
||||
},
|
||||
'sanitize.dangerous-content': {
|
||||
key: 'file.content',
|
||||
what: 'uploaded a file whose content had to have active markup removed',
|
||||
},
|
||||
'sanitize.dangerous-text': {
|
||||
key: 'xss.input',
|
||||
what: 'submitted text that had to have active markup removed',
|
||||
},
|
||||
|
||||
// --- Other common attacks -------------------------------------------------
|
||||
'spoof.forwarded-header': {
|
||||
key: 'spoofing.xff',
|
||||
what: 'sent a forwarded-for header that is not trusted here',
|
||||
},
|
||||
'brute.login-lockout': {
|
||||
key: 'brute.login',
|
||||
what: 'was locked out after repeated failed logins',
|
||||
},
|
||||
};
|
||||
|
||||
// The id used when a caller passes something not in the catalog. A canary with
|
||||
// an unknown id must still be RECORDED - losing the event would be the worst
|
||||
// possible failure mode for a tripwire - it is simply recorded generically.
|
||||
const UNKNOWN_CANARY = {
|
||||
key: 'authz.canary',
|
||||
what: 'tripped a canary',
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a canary. Never throws and never returns undefined.
|
||||
* @param {string} id
|
||||
* @return {{key: string, what: string, id: string, known: boolean}}
|
||||
*/
|
||||
function canaryFor(id) {
|
||||
const entry = (typeof id === 'string' && CANARIES[id]) || null;
|
||||
return {
|
||||
id: typeof id === 'string' && id ? id : 'unknown',
|
||||
key: (entry || UNKNOWN_CANARY).key,
|
||||
what: (entry || UNKNOWN_CANARY).what,
|
||||
known: !!entry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every catalogued canary id, for the tests and the docs. */
|
||||
function canaryIds() {
|
||||
return Object.keys(CANARIES).sort();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the rate limit
|
||||
|
||||
const DEFAULTS = {
|
||||
// How long one (canary, actor) pair is aggregated for. The first trip in a
|
||||
// window is recorded immediately - an admin should see a probe promptly - and
|
||||
// everything else in the window is counted.
|
||||
windowMs: 60 * 1000,
|
||||
// How many (canary, actor) pairs are tracked at once. An attacker can vary the
|
||||
// actor (a fresh IP per request over a botnet), so this map is the thing that
|
||||
// would grow without a cap. At the cap the OLDEST entry is dropped: losing the
|
||||
// count of an old, already-reported probe is much cheaper than unbounded
|
||||
// memory, and the first trip of every pair was recorded regardless.
|
||||
maxTracked: 5000,
|
||||
// A hard ceiling on how many events one pair can produce, however long the
|
||||
// attack runs: after this many summaries the pair keeps counting but stops
|
||||
// writing until it goes quiet for a window. Prevents a slow-but-endless probe
|
||||
// from writing one row a minute forever.
|
||||
maxEventsPerPair: 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides, for each trip, whether to WRITE an event now and what count it
|
||||
* carries. Pure: the clock is passed in, so the tests drive time directly.
|
||||
*
|
||||
* The contract:
|
||||
* - the first trip of a (canary, actor) pair writes immediately, count 1;
|
||||
* - further trips inside the window write nothing and are counted;
|
||||
* - the first trip AFTER the window closes writes a summary carrying the
|
||||
* suppressed count, and opens a new window;
|
||||
* - a pair that has already written `maxEventsPerPair` summaries stops
|
||||
* writing until it goes quiet for a whole window;
|
||||
* - the tracking map never exceeds `maxTracked` entries.
|
||||
*/
|
||||
class CanaryRateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.windowMs = options.windowMs || DEFAULTS.windowMs;
|
||||
this.maxTracked = options.maxTracked || DEFAULTS.maxTracked;
|
||||
this.maxEventsPerPair = options.maxEventsPerPair || DEFAULTS.maxEventsPerPair;
|
||||
// Insertion-ordered, which is what makes the oldest entry cheap to evict.
|
||||
this._pairs = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pairKey identifies (canary, actor)
|
||||
* @param {number} now epoch ms
|
||||
* @return {{write: boolean, count: number, suppressed: number}}
|
||||
* `write` - record an event now. `count` - how many trips it stands for.
|
||||
* `suppressed` - how many of those were not individually recorded.
|
||||
*/
|
||||
consider(pairKey, now) {
|
||||
const key = typeof pairKey === 'string' && pairKey ? pairKey : 'unknown';
|
||||
const at = typeof now === 'number' && isFinite(now) ? now : 0;
|
||||
const existing = this._pairs.get(key);
|
||||
|
||||
if (!existing) {
|
||||
this._evictIfFull();
|
||||
this._pairs.set(key, { windowStart: at, seen: 1, written: 1, lastSeen: at });
|
||||
return { write: true, count: 1, suppressed: 0 };
|
||||
}
|
||||
|
||||
// Re-insert so the map stays ordered by RECENCY, not by first sighting;
|
||||
// otherwise a long-running attacker would be evicted before a one-off probe.
|
||||
this._pairs.delete(key);
|
||||
this._pairs.set(key, existing);
|
||||
existing.lastSeen = at;
|
||||
|
||||
const windowOpen = at - existing.windowStart < this.windowMs;
|
||||
if (windowOpen) {
|
||||
existing.seen += 1;
|
||||
return { write: false, count: existing.seen, suppressed: existing.seen - 1 };
|
||||
}
|
||||
|
||||
// The window closed. Flush what it accumulated, then start a new one.
|
||||
const suppressed = Math.max(0, existing.seen - 1);
|
||||
existing.windowStart = at;
|
||||
const total = existing.seen + 1;
|
||||
existing.seen = 1;
|
||||
|
||||
if (existing.written >= this.maxEventsPerPair) {
|
||||
// Still counting, no longer writing: an endless probe must not be able to
|
||||
// write forever, and by now the admin has 60 events saying so.
|
||||
return { write: false, count: total, suppressed };
|
||||
}
|
||||
|
||||
existing.written += 1;
|
||||
return { write: true, count: total, suppressed };
|
||||
}
|
||||
|
||||
/** Drop pairs whose window closed long ago; called by the runtime's sweeper. */
|
||||
sweep(now, idleMs) {
|
||||
const cutoff = (typeof now === 'number' ? now : 0) - (idleMs || this.windowMs * 10);
|
||||
let dropped = 0;
|
||||
for (const [key, entry] of this._pairs) {
|
||||
if (entry.lastSeen < cutoff) {
|
||||
this._pairs.delete(key);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this._pairs.size;
|
||||
}
|
||||
|
||||
_evictIfFull() {
|
||||
while (this._pairs.size >= this.maxTracked) {
|
||||
const oldest = this._pairs.keys().next();
|
||||
if (oldest.done) return;
|
||||
this._pairs.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key one canary + one actor are aggregated under. An unauthenticated actor
|
||||
* is keyed by IP alone; a logged-in one by userId, so switching IP mid-attack
|
||||
* does not reset their counter.
|
||||
* @param {string} canaryId
|
||||
* @param {{userId?: string, ip?: string}} actor
|
||||
* @return {string}
|
||||
*/
|
||||
function canaryPairKey(canaryId, actor = {}) {
|
||||
const id = typeof canaryId === 'string' && canaryId ? canaryId : 'unknown';
|
||||
const userId = actor && typeof actor.userId === 'string' && actor.userId ? actor.userId : '';
|
||||
const ip = actor && typeof actor.ip === 'string' && actor.ip ? actor.ip : 'unknown-ip';
|
||||
return userId ? `${id} | ||||