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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - 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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 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--.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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - 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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - 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