From 98e5d747bb089c79acf5e435e85c3590ba57eb21 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Thu, 24 Sep 2026 00:31:13 +0200 Subject: [PATCH 1/2] Cache the release builds, and stop CI waiting on rm -rf release.yml had no Rust cache at all, so every release compiled all 799 crates from scratch on six platforms. Of the Linux leg's 568 seconds inside tauri-action, 408 were cargo; on windows-latest it was roughly 600 of 694. test.yml has cached since it was written - the build matrix simply never got one. Each leg now carries a rust-cache keyed by a new `cache_key` matrix field, because rust-cache keys on the job, the runner's os and arch, rustc and the lockfile, and on none of the matrix values: the two macOS legs would otherwise share one key and restore each other's architecture. Both caches save only from `main`. A run may restore caches scoped to its own ref or to the default branch, which here is `main`, so a cache written from `develop` is invisible to a release and one written from a pull request is invisible to everything. Twelve rust entries had accumulated that way - four of them scoped to a merged PR and unreadable by anything - filling 8.6 GB of GitHub's 10 GB budget, at which point the cache evicts itself and nothing stays warm. Three entries now, refreshed once per release. The Free Disk Space steps blocked for 91 seconds in a test job whose cargo test takes 24, and 50 in a build leg. Almost all of it is /usr/local/lib/android, nine gigabytes of small files. They run in the background now: the disk has to be free before cargo needs it, not before the next step starts. $AGENT_TOOLSDIRECTORY is no longer deleted, which is what forced the step to run ahead of setup-node and is the one path a backgrounded rm could have raced. Also: the ubuntu frontend leg ran the same fourteen test files twice, once plain and once under coverage; clippy and fmt ran on three operating systems for a platform-independent, advisory result; npm ci took 123 seconds on windows-11-arm with an audit and a funding lookup it has no use for; and no job in test.yml had a timeout, so one run sat thirteen minutes in a runner queue with nothing to bound it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P4Pn5YzK5yErCEZkV47WmN --- .github/workflows/release.yml | 64 ++++++++++++++++++++++++++++------- .github/workflows/test.yml | 58 ++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3b0a26..0da9a2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,20 +237,32 @@ jobs: # succeeds and `cleanup` deletes the draft otherwise, so two more legs are two more ways # for a release to fail wholesale. That is the intended trade - a release that ships half # the platforms it announces is worse than one that does not ship. + # + # `cache_key` names each leg's Rust cache. rust-cache keys on the job name, the runner's + # os and arch, the rustc version and the lockfile - and on none of the matrix values, so + # the two macOS legs would otherwise compute one key between them and each would restore + # the other architecture's artifacts. The other four are already distinguished by arch, + # and are named here anyway so no leg's cache identity is implicit. matrix: include: - platform: 'macos-latest' # for Arm64 args: '--target aarch64-apple-darwin' + cache_key: 'darwin-aarch64' - platform: 'macos-latest' # for Intel args: '--target x86_64-apple-darwin' + cache_key: 'darwin-x86_64' - platform: 'ubuntu-22.04' # for Linux x86_64 args: '' + cache_key: 'linux-x86_64' - platform: 'ubuntu-22.04-arm' # for Linux arm64 args: '' + cache_key: 'linux-aarch64' - platform: 'windows-latest' # for Windows x86_64 args: '' + cache_key: 'windows-x86_64' - platform: 'windows-11-arm' # for Windows arm64 args: '' + cache_key: 'windows-aarch64' runs-on: ${{ matrix.platform }} # A hung step otherwise runs to GitHub's 6 hour default before anyone notices. The slowest @@ -262,22 +274,23 @@ jobs: with: fetch-depth: 0 - # Must run *before* setup-node: this deletes $AGENT_TOOLSDIRECTORY, which is the tool - # cache setup-node installs Node into. Running it afterwards removes the very toolchain - # the next steps depend on. + # Backgrounded, and no longer ordered against setup-node. It used to delete + # $AGENT_TOOLSDIRECTORY, which is where setup-node installs Node - so it had to run + # first, and it blocked for fifty seconds doing it. Dropping that one path retires the + # ordering constraint, and the rm can then run alongside apt and npm ci instead of in + # front of them. + # + # What is left still frees ~15 GB. A release build needs nowhere near it: apt adds + # 216 MB and the release target directory is a few gigabytes against ~21 GB free. # # `startsWith`, not `==`: ubuntu-22.04-arm is a different runner label and an exact # match would quietly skip this on the arm64 leg. - - name: Free Disk Space (Ubuntu) + - name: Reclaim disk in the background if: startsWith(matrix.platform, 'ubuntu-') run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - if [ -n "${AGENT_TOOLSDIRECTORY:-}" ]; then - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - fi + df -h / + sudo rm -rf /usr/local/lib/android /usr/share/dotnet >/dev/null 2>&1 & + sudo rm -rf /opt/ghc /usr/local/share/boost >/dev/null 2>&1 & - name: setup node uses: actions/setup-node@v7 @@ -291,6 +304,30 @@ jobs: # Those targets are only used on macos runners so it's skip on linux and windows targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + # This workflow had no Rust cache at all, so every release compiled all 799 crates from + # scratch on six platforms - 408 of the Linux leg's 568 seconds inside tauri-action, and + # roughly 600 of windows-latest's 694. test.yml has cached since it was written; the + # build matrix simply never got one. + - name: Cache Rust dependencies + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + # One key per leg - see the note on `cache_key` in the matrix above. Spelled as its + # own matrix field rather than derived from `args`, which is empty on four legs and + # contains a space on the other two, and a cache key is a poor place for either. + key: ${{ matrix.cache_key }} + + # `main` because it is the default branch, which is the one scope every run can + # read: a cache written from any other ref is invisible to everything but that ref. + # A workflow_dispatch rehearsal also runs on `main` and so writes too, which is + # wanted - it warms the same six keys a real release would, at no extra footprint. + save-if: ${{ github.ref == 'refs/heads/main' }} + + # Left at its default, said out loud: a leg that dies mid-build must not persist a + # half-written target directory into the next release. + cache-on-failure: false + # Same widening as above, and it matters more here: skipping this step does not fail # the leg, it fails the build twenty minutes later with a missing webkit2gtk. - name: install dependencies (ubuntu only) @@ -330,8 +367,11 @@ jobs: echo "::error::Could not install Linux build dependencies after 3 attempts." exit 1 + # The flags are worth their noise on the arm64 legs: windows-11-arm spent 123 seconds + # here, where x86_64 takes 19. An audit and a funding lookup are two network round + # trips a release build has no use for. - name: install frontend dependencies - run: npm ci + run: npm ci --prefer-offline --no-audit --fund=false # Signing is enabled by exporting secrets into $GITHUB_ENV only when they are actually # configured. Setting them directly on the build step would not work: a secret that does diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c1df1c..6cbb6c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,11 @@ jobs: changelog: name: Changelog runs-on: ubuntu-latest + # Bounded because these are required contexts: a job that hangs, or sits in a runner + # queue, blocks a merge with no signal at all. One Tests run waited thirteen minutes for + # a windows-latest runner and nothing anywhere would have stopped it short of GitHub's + # six-hour default. + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 @@ -56,6 +61,7 @@ jobs: frontend-tests: name: Frontend Tests runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -74,10 +80,13 @@ jobs: - name: Install dependencies run: npm ci + # Split by OS rather than run both: `test:coverage` is `vitest run --coverage`, the + # same fourteen files end to end, so ubuntu was running the whole suite twice. - name: Run tests + if: matrix.os != 'ubuntu-latest' run: npm run test - - name: Generate coverage report + - name: Run tests with coverage if: matrix.os == 'ubuntu-latest' run: npm run test:coverage @@ -94,6 +103,7 @@ jobs: backend-tests: name: Backend Tests (Rust) runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -103,16 +113,22 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - - name: Free Disk Space (Ubuntu) + # Backgrounded, because as a blocking step this cost 91 seconds in a job whose actual + # work - `cargo test` - takes 24. Almost all of it is /usr/local/lib/android, nine + # gigabytes of small files. What matters is that the disk is free before the build + # needs it, not before the next step starts, so apt and cargo now run alongside it. + # + # $AGENT_TOOLSDIRECTORY is deliberately no longer deleted. Removing it is what forced + # this step to run before any setup-node, and a backgrounded rm would race whatever + # installs there. What is left still frees ~15 GB, against a debug target directory + # that is under a gigabyte since `[profile.dev.package."*"] debug = false` landed in + # Cargo.toml - the 37 GB build this step was written for cannot happen any more. + - name: Reclaim disk in the background if: matrix.os == 'ubuntu-latest' run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - if [ -n "${AGENT_TOOLSDIRECTORY:-}" ]; then - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - fi + df -h / + sudo rm -rf /usr/local/lib/android /usr/share/dotnet >/dev/null 2>&1 & + sudo rm -rf /opt/ghc /usr/local/share/boost >/dev/null 2>&1 & - name: Install dependencies (Ubuntu only) if: matrix.os == 'ubuntu-latest' @@ -128,10 +144,27 @@ jobs: with: components: rustfmt, clippy + # `save-if` is what keeps this affordable. Every push and every pull_request run used to + # write a fresh entry per OS - ~800 MiB each - and twelve of them had grown to fill + # 8.6 GB of GitHub's 10 GB per-repository budget, at which point the cache evicts itself + # and nothing stays warm. Four of the twelve were scoped to a pull request that had long + # since merged, and could never be read again by anything. + # + # `main`, not `develop`, and that is the whole point of the line. A run may restore only + # caches scoped to its own ref or to the default branch, and the default branch here is + # `main` - so a cache written from `develop` is invisible to a release, and one written + # from a PR is invisible to everything. Saving from `main` is the only scope every run + # can read: develop pushes, PRs and releases alike restore from it. + # + # This suite does reach `main`: release.yml calls it through `workflow_call`, where + # `github.ref` is the caller's ref. So the cache refreshes once per release, which is + # roughly daily. A `Cargo.lock` or rustc change between releases means develop runs cold + # until the next one - the honest cost of three entries instead of nine. - name: Cache Rust dependencies uses: swatinem/rust-cache@v2 with: workspaces: src-tauri -> target + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run Rust tests working-directory: src-tauri @@ -149,12 +182,18 @@ jobs: # over ten lint families - but it is a tree-wide reformat that would bury `git blame`, so # it belongs in its own commit rather than riding along with a CI change. When that lands, # drop the `continue-on-error` and the "(advisory)" from both names. + # + # One OS, not three. Neither lint is platform-dependent, and clippy's check-mode + # artifacts carry different fingerprints from the test build's - so on every leg it + # was a second near-full compile, for a result the other two legs had already given. - name: Run Clippy (advisory, non-gating) + if: matrix.os == 'ubuntu-latest' working-directory: src-tauri run: cargo clippy -- -D warnings continue-on-error: true - name: Check formatting (advisory, non-gating) + if: matrix.os == 'ubuntu-latest' working-directory: src-tauri run: cargo fmt --check continue-on-error: true @@ -162,6 +201,7 @@ jobs: type-check: name: TypeScript Type Check runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code From 0333b756b095881ffd59ddc403a9e689f5ab91b2 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Thu, 24 Sep 2026 00:45:48 +0200 Subject: [PATCH 2/2] Let Dependabot and CodeQL watch the app, without gating a release Dependency graph and Dependabot alerts are repository settings and were already on; what was missing is the config file that turns on scheduled version updates, and a scanner. Dependabot aims at the default branch unless told otherwise, and the default branch here is main - where a merge is the release decision. Every ecosystem therefore sets target-branch: develop. A dependency pull request merged to main would not in fact release (the tag for the current version exists, so prepare stops at should_release=false) but it would run the whole suite to work that out, and it is the wrong branch for how this repository is meant to move. Updates are grouped and monthly because of the Rust cache. Its key hashes Cargo.lock, so any crate bump is a miss and the pull request compiles all 799 crates cold on three operating systems. One grouped pull request a month pays that once. Majors stay individual - those are the ones worth reading. The github-actions ecosystem is included because it is what would have flagged actions-rs/toolchain being archived, which test.yml had to notice by hand, and cargo git sources like `drag` stay manual because Dependabot does not update them. CodeQL analyses all three languages without a build - Rust's buildless extractor went generally available in October 2025 - so it reads src-tauri without compiling any of it. It runs on pull requests and on a weekly schedule, never from test.yml or release.yml, and it must not be added to the required status checks: a scanner whose query pack updates on its own schedule is not a reason to refuse to ship a binary, which is the argument the advisory clippy and fmt steps already make. Worth knowing before reading a clean result: CodeQL's JavaScript extractor does not parse .svelte files, so src/lib/**/*.ts is covered and the script blocks inside components are not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P4Pn5YzK5yErCEZkV47WmN --- .github/dependabot.yml | 57 +++++++++++++++++++++++++++ .github/workflows/codeql.yml | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6fa4147 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +# Version updates. Alerts and security updates are repository settings and need nothing +# here; this file is only the scheduled "bump things that are behind" pull requests. +# +# Two settings below are load-bearing rather than taste, and both are explained where they +# appear: `target-branch`, because the default would aim at the release branch, and the +# `groups` blocks, because an ungrouped cargo update is expensive in a way that is not +# obvious from the diff. +version: 2 + +updates: + # The workflows themselves. This is the cheapest of the three and arguably the most + # useful: it is what would have flagged actions-rs/toolchain being archived in 2023, + # which test.yml had to notice by hand. + - package-ecosystem: "github-actions" + directory: "/" + # Dependabot aims at the default branch unless told otherwise, and the default branch + # here is `main` - where a merge *is* the release decision. A dependency pull request + # merged there would start release.yml. It would not actually release (the tag for the + # current version already exists, so `prepare` decides should_release=false and stops), + # but it would run the whole suite to reach that conclusion, and it is the wrong branch + # for how this repository works. Everything lands on `develop` and is promoted. + target-branch: "develop" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + groups: + # One pull request for everything routine. Majors stay individual - those are the + # ones that need reading. + actions-minor-and-patch: + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/" + target-branch: "develop" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + groups: + npm-minor-and-patch: + update-types: ["minor", "patch"] + + # The Rust crate. Grouping matters most here, and the reason is the Rust cache: its key + # includes a hash of Cargo.lock, so *any* crate bump is a cache miss, and the pull request + # then compiles all 799 crates cold on three operating systems. One grouped pull request a + # month pays that once; a pull request per crate would pay it over and over. + # + # The `drag` dependency is a git dependency (crabnebula-dev/drag-rs) and Dependabot does + # not update cargo git sources, so that one stays manual. + - package-ecosystem: "cargo" + directory: "/src-tauri" + target-branch: "develop" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + groups: + cargo-minor-and-patch: + update-types: ["minor", "patch"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b61b995 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,76 @@ +name: CodeQL + +# Static analysis, deliberately kept off the release path. +# +# This is a separate workflow, not a job in test.yml and not called by release.yml, and it +# must stay that way. test.yml is the gate that decides whether a merge to main ships a +# binary; a finding from a scanner that updates its query pack on its own schedule is not a +# reason to refuse to release. The same argument the clippy and fmt steps already make in +# test.yml, for the same reason - so do not add these jobs to the repository's required +# status checks. +# +# All three languages analyse without a build. Rust analysis went generally available in +# October 2025 as a buildless extractor, which is what makes this affordable here: it reads +# src-tauri without compiling any of its 799 crates. +on: + pull_request: + branches: [main, develop] + # The default branch needs analysing on its own for the Security tab to have a baseline to + # compare pull requests against, and nothing pushes to main except a release. A schedule + # runs against the default branch, which covers it without putting a second workflow in + # front of every merge. + schedule: + - cron: '41 4 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # The only one that is not read-only: this is what lets the run upload its results to + # the Security tab. Everything else is narrowed to read. + security-events: write + actions: read + contents: read + + strategy: + # One language failing should still leave the other two reporting. + fail-fast: false + matrix: + include: + # The workflows themselves, and the most interesting of the three in this + # repository: release.yml interpolates ${{ }} expressions directly into `run:` + # shell blocks in several places, which is exactly the shape this pack looks at. + - language: actions + build-mode: none + + # Covers src/lib/**/*.ts, which is where the logic lives. Note what it does *not* + # cover: CodeQL's extractor does not parse .svelte files, so the script blocks + # inside components are invisible to it. Worth remembering before reading a clean + # result as "the frontend is clean". + - language: javascript-typescript + build-mode: none + + - language: rust + build-mode: none + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Analyze + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}"