trilium/.github/workflows/dev.yml
Elian Doran fc7b6a6846
ci: follow vitest 5's project uniqueness and blob report path
Two breakages that the typecheck failure was hiding, since every step after it
in the "Test development" job was skipped and the CKEditor job never got as far
as uploading anything.

The root config globs both `packages/*/vitest.config.ts` and
`packages/*/vite.config.ts`. ckeditor5 is the only package with both, and its
vite.config.ts merely builds the library — it declares no tests at all. Vitest 4
registered it anyway as a second project sharing the package's name; vitest 5
rejects the duplicate outright and `pnpm scripts:test` cannot start:

    Error: Project name "@triliumnext/ckeditor5" from
    "packages/ckeditor5/vitest.config.ts" is not unique.

Blob reports also moved from `.vitest-reports/` to `.vitest/blob/`, alongside
the attachments that moved to `.vitest/`. The shard jobs were writing them where
nothing looked, so the upload failed with "No files were found" — after the
tests themselves had passed. The merge job reads the same path and would have
failed next.

Verified by running what CI runs: both shards, then `--mergeReports --coverage`,
which replays 127 files / 1626 tests and clears the gate at
99.91/99.54/100/99.98.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:31:58 +02:00

640 lines
24 KiB
YAML

name: Dev
on:
push:
branches:
- main
- standalone
- "release/*"
pull_request:
branches:
- main
- standalone
- "release/*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: ${{ github.repository}}
TEST_TAG: ${{ github.repository}}:test
permissions:
pull-requests: write # for PR comments
jobs:
# Decides which of the two heavyweight suites a given push/PR can actually
# affect. Both suites also run the shared trilium-core spec set, so anything
# under that package (or the workspace roots) has to trigger both.
changes:
name: Detect affected suites
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
standalone: ${{ steps.filter.outputs.standalone }}
ckeditor5: ${{ steps.filter.outputs.ckeditor5 }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
# Workspace-wide inputs: these change how anything installs or builds.
# Kept as its own anchor so the ckeditor5 filter can pick it up
# without inheriting the much wider trilium-core closure below.
workspace: &workspace
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'tsconfig*.json'
- '.npmrc'
- '.nvmrc'
# pnpm-workspace.yaml patches @excalidraw/excalidraw, so editing a
# patch changes what actually gets installed.
- 'patches/**'
- '.github/workflows/dev.yml'
# apps/server and apps/standalone each include
# ../../packages/trilium-core/src/**/*.spec.ts in their vitest
# `include` — the standalone suite exists precisely to re-run that
# set under happy-dom + sql.js — so both need core and everything
# core pulls in. turndown-plugin-gfm is here because trilium-core
# depends on it; it is not a direct dependency of either app.
core: &core
- 'packages/trilium-core/**'
- 'packages/commons/**'
- 'packages/turndown-plugin-gfm/**'
- 'packages/trilium-backup-container/**'
- 'packages/highlightjs/**'
server:
- *workspace
- *core
- 'apps/server/**'
# Widest closure in the repo: apps/standalone depends on eight
# workspace packages. It reaches into apps/client too, but only
# through ../../client/src/index.js, which main.spec.ts mocks — no
# client code executes in this suite, so apps/client is left out.
standalone:
- *workspace
- *core
- 'apps/standalone/**'
- 'packages/ckeditor5/**'
- 'packages/codemirror/**'
- 'packages/share-theme/**'
- 'packages/splitjs/**'
# Narrowest closure of the three: the aggregate depends only on
# commons. Note this filter does not guard the t()/translation-key
# contract — apps/client/src/services/i18n.spec.ts reads
# packages/ckeditor5/src off disk to enforce that, and it runs in
# test_dev, which is ungated.
ckeditor5:
- *workspace
- 'packages/ckeditor5/**'
- 'packages/commons/**'
test_dev:
name: Test development
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Set up node & dependencies
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Run the repo-script tests
run: pnpm scripts:test
- name: Run the client-side tests
id: test-client
run: pnpm run --filter=client test --coverage
- name: Upload client test report
uses: actions/upload-artifact@v7
if: always()
with:
name: client-test-report
path: apps/client/test-output/vitest/html/
retention-days: 30
- name: Upload client coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-client.outcome == 'success' }}
with:
files: apps/client/test-output/vitest/coverage/lcov.info
flags: client
disable_search: true
fail_ci_if_error: false
- name: Upload client test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-client.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: apps/client/test-output/vitest/junit.xml
flags: client
disable_search: true
fail_ci_if_error: false
- name: Run the desktop tests
id: test-desktop
run: pnpm run --filter=desktop test --coverage
- name: Upload desktop coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-desktop.outcome == 'success' }}
with:
files: apps/desktop/test-output/vitest/coverage/lcov.info
flags: desktop
disable_search: true
fail_ci_if_error: false
- name: Upload desktop test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-desktop.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: apps/desktop/test-output/vitest/junit.xml
flags: desktop
disable_search: true
fail_ci_if_error: false
- name: Run the commons tests
id: test-commons
run: pnpm run --filter=commons test --coverage
- name: Upload commons coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-commons.outcome == 'success' }}
with:
files: packages/commons/test-output/vitest/coverage/lcov.info
flags: commons
disable_search: true
fail_ci_if_error: false
- name: Upload commons test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-commons.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/commons/test-output/vitest/junit.xml
flags: commons
disable_search: true
fail_ci_if_error: false
- name: Run the codemirror tests
id: test-codemirror
run: pnpm run --filter=codemirror test --coverage
- name: Upload codemirror coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-codemirror.outcome == 'success' }}
with:
files: packages/codemirror/test-output/vitest/coverage/lcov.info
flags: codemirror
disable_search: true
fail_ci_if_error: false
- name: Upload codemirror test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-codemirror.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/codemirror/test-output/vitest/junit.xml
flags: codemirror
disable_search: true
fail_ci_if_error: false
- name: Run the highlightjs tests
id: test-highlightjs
run: pnpm run --filter=highlightjs test --coverage
- name: Upload highlightjs coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-highlightjs.outcome == 'success' }}
with:
files: packages/highlightjs/test-output/vitest/coverage/lcov.info
flags: highlightjs
disable_search: true
fail_ci_if_error: false
- name: Upload highlightjs test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-highlightjs.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/highlightjs/test-output/vitest/junit.xml
flags: highlightjs
disable_search: true
fail_ci_if_error: false
- name: Run the pdfjs-viewer tests
id: test-pdfjs-viewer
run: pnpm run --filter=pdfjs-viewer test --coverage
- name: Upload pdfjs-viewer coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-pdfjs-viewer.outcome == 'success' }}
with:
files: packages/pdfjs-viewer/test-output/vitest/coverage/lcov.info
flags: pdfjs-viewer
disable_search: true
fail_ci_if_error: false
- name: Upload pdfjs-viewer test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-pdfjs-viewer.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/pdfjs-viewer/test-output/vitest/junit.xml
flags: pdfjs-viewer
disable_search: true
fail_ci_if_error: false
- name: Run the rest of the tests
run: pnpm run --filter=\!client --filter=\!standalone --filter=\!server --filter=\!desktop --filter=\!commons --filter=\!ckeditor5 --filter=\!codemirror --filter=\!highlightjs --filter=\!pdfjs-viewer test
# The two suites below are split out of test_dev so they run concurrently with
# it (and each other) rather than queueing behind the client/package suites.
#
# Each job always starts and gates its *steps* on the filter, rather than
# carrying a job-level `if:`. A job skipped at the job level reports no result
# to a branch-protection required check, and propagates the skip through
# `needs:` to build_docker. Gating the steps keeps the job green and the
# dependency graph intact on a run that touches neither suite; the cost is one
# checkout on an unaffected run.
test_server:
name: Test server
runs-on: ubuntu-latest
needs: changes
env:
AFFECTED: ${{ needs.changes.outputs.server }}
steps:
- name: Checkout the repository
if: env.AFFECTED == 'true'
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: env.AFFECTED == 'true'
- name: Set up node & dependencies
if: env.AFFECTED == 'true'
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
if: env.AFFECTED == 'true'
run: pnpm install --frozen-lockfile
- name: Run the server-side tests
id: test-server
if: env.AFFECTED == 'true'
run: pnpm run --filter=server test --coverage
- name: Upload server test report
uses: actions/upload-artifact@v7
if: ${{ !cancelled() && steps.test-server.outcome != 'skipped' }}
with:
name: server-test-report
path: apps/server/test-output/vitest/html/
retention-days: 30
- name: Upload server coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-server.outcome == 'success' }}
with:
files: apps/server/test-output/vitest/coverage/lcov.info
flags: server
disable_search: true
fail_ci_if_error: false
- name: Upload server test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-server.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: apps/server/test-output/vitest/junit.xml
flags: server
disable_search: true
fail_ci_if_error: false
test_standalone:
name: Test standalone
runs-on: ubuntu-latest
needs: changes
env:
AFFECTED: ${{ needs.changes.outputs.standalone }}
steps:
- name: Checkout the repository
if: env.AFFECTED == 'true'
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: env.AFFECTED == 'true'
- name: Set up node & dependencies
if: env.AFFECTED == 'true'
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
if: env.AFFECTED == 'true'
run: pnpm install --frozen-lockfile
- name: Run the standalone tests
id: test-standalone
if: env.AFFECTED == 'true'
# Runs the same trilium-core spec set as the server suite, but in
# happy-dom + sql.js WASM via BrowserSqlProvider (see
# apps/standalone/src/test_setup.ts). Catches differences
# between the Node-side and browser-side runtimes.
run: pnpm run --filter=standalone test --coverage
- name: Upload standalone coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-standalone.outcome == 'success' }}
with:
files: apps/standalone/test-output/vitest/coverage/lcov.info
flags: standalone
disable_search: true
fail_ci_if_error: false
- name: Upload standalone test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-standalone.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: apps/standalone/test-output/vitest/junit.xml
flags: standalone
disable_search: true
fail_ci_if_error: false
# The two shards the suite was already split into now run as separate jobs
# rather than back-to-back in one step, and a merge job evaluates the coverage
# gate over the combined blob reports. Sharding exists for renderer memory, not
# speed (see the comment on the test step); running the shards concurrently is
# what makes it also a speedup.
test_ckeditor5:
name: Test CKEditor 5 (shard ${{ matrix.shard }})
runs-on: ubuntu-latest
needs: changes
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
AFFECTED: ${{ needs.changes.outputs.ckeditor5 }}
steps:
- name: Checkout the repository
if: env.AFFECTED == 'true'
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: env.AFFECTED == 'true'
- name: Set up node & dependencies
if: env.AFFECTED == 'true'
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
if: env.AFFECTED == 'true'
run: pnpm install --frozen-lockfile
- name: Install Playwright's Chromium
if: env.AFFECTED == 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run the CKEditor 5 aggregate tests
id: test-ckeditor5
if: env.AFFECTED == 'true'
# The suite drives a real headless Chromium through Playwright. When the browser session
# fails to start, vitest waits on it rather than erroring, so cap the step instead of
# letting it sit until the job's default timeout.
timeout-minutes: 15
run: |
# Browser mode keeps one page for the whole run, and v8 coverage data accumulates in it:
# measured locally, the heap reaches ~850MB by the 100th spec file with --coverage against
# ~240MB without it. That is where a CI runner's renderer gives out — the session either
# drops mid-run or stops responding. Each shard is ~60 files, peaking around 500MB. The
# thresholds are zeroed here and evaluated once over the merged report instead, so the
# gate still covers the whole suite rather than each shard separately.
pnpm run --filter=ckeditor5 test --shard=${{ matrix.shard }}/${{ strategy.job-total }} \
--reporter=blob --coverage \
--coverage.thresholds.lines=0 --coverage.thresholds.functions=0 \
--coverage.thresholds.branches=0 --coverage.thresholds.statements=0
# Vitest names blobs `.vitest/blob/blob-<index>-<count>.json`, so the two
# shards produce distinct filenames and merge-multiple cannot clobber them.
# That directory is dot-prefixed, and upload-artifact skips hidden paths
# unless told otherwise — without include-hidden-files it silently matches
# nothing.
- name: Upload the shard blob report
uses: actions/upload-artifact@v7
if: ${{ !cancelled() && steps.test-ckeditor5.outcome == 'success' }}
with:
name: ckeditor5-blob-${{ matrix.shard }}
path: packages/ckeditor5/.vitest/blob/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
test_ckeditor5_merge:
name: CKEditor 5 coverage gate
runs-on: ubuntu-latest
needs:
- changes
- test_ckeditor5
env:
AFFECTED: ${{ needs.changes.outputs.ckeditor5 }}
steps:
- name: Checkout the repository
if: env.AFFECTED == 'true'
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
if: env.AFFECTED == 'true'
- name: Set up node & dependencies
if: env.AFFECTED == 'true'
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
if: env.AFFECTED == 'true'
run: pnpm install --frozen-lockfile
- name: Download the shard blob reports
if: env.AFFECTED == 'true'
uses: actions/download-artifact@v8
with:
pattern: ckeditor5-blob-*
merge-multiple: true
path: packages/ckeditor5/.vitest/blob
# Re-runs no tests; it replays the blobs to produce the junit and lcov
# output and to evaluate the package's real coverage thresholds over the
# whole suite.
- name: Merge the shard reports and evaluate the coverage gate
id: test-ckeditor5
if: env.AFFECTED == 'true'
run: pnpm run --filter=ckeditor5 test --mergeReports --coverage
- name: Upload ckeditor5 coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-ckeditor5.outcome == 'success' }}
with:
files: packages/ckeditor5/test-output/vitest/coverage/lcov.info
flags: ckeditor5
disable_search: true
fail_ci_if_error: false
- name: Upload ckeditor5 test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if: ${{ !cancelled() && steps.test-ckeditor5.outcome != 'skipped' }}
with:
report_type: test_results
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/ckeditor5/test-output/vitest/junit.xml
flags: ckeditor5
disable_search: true
fail_ci_if_error: false
# Deliberately ungated: this validates that the client and server build and
# that the image assembles, none of which depends on a test outcome. Waiting
# for the suites put ~4m of Docker work in series behind the slowest of them
# for no signal. It now runs alongside them, so the workflow finishes with the
# longest test job rather than after it.
#
# The trade is that a push whose tests fail still spends runner time building
# images. That is the accepted cost of taking the Docker work off the critical
# path; the concurrency group above still cancels superseded runs.
build_docker:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Set up node & dependencies
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger client build
run: pnpm client:build
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Trigger server build
run: pnpm run server:build
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: apps/server
cache-from: type=gha
cache-to: type=gha,mode=max
test_docker:
name: Check Docker build
runs-on: ubuntu-latest
# Kept: build_docker populates the type=gha buildx cache this job reads. The
# two together (~4m) still finish well inside the slowest test job, so
# breaking the chain would buy nothing and would leave both jobs racing to
# write the same cache.
needs:
- build_docker
strategy:
matrix:
include:
- dockerfile: Dockerfile.alpine
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Set up node & dependencies
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger build
run: pnpm server:build
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build and export to Docker
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
load: true
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Validate container run output
run: |
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --name trilium_local ${{ env.TEST_TAG }})
echo "Container ID: $CONTAINER_ID"
- name: Wait for the healthchecks to pass
uses: stringbean/docker-healthcheck-action@a958d329225ccbd485766815734e01c335e62bd4 # v3.0.0
with:
container: trilium_local
wait-time: 50
require-status: running
require-healthy: true
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
- name: Print entire log
if: always()
run: journalctl -u docker CONTAINER_NAME=trilium_local --no-pager