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 }}" 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