Apollo Download ManagerApollo Download Manager
On this page

CI design

Apollo Download Manager's CI runs on GitHub Actions. Since 2026-10-31 the repository is public, owned by Singlbase Technologies Limited, and GitHub's standard runners are free for public repositories (billing). It was designed for a private repository with a monthly quota of minutes, so every design choice below still keeps runs short: the checks that matter run on every change, and nothing runs twice. The cost figures below are from those private months.

This page is the design. The workflows are written in #13, #14, #16 to #19 and #91. Every figure below is labelled as measured or assumed. Assumed CI times get replaced by measured ones as the jobs start running (#14).

History: CI by hand only (2026-10-24 to 2026-10-31)

To save Actions minutes while the repository was private, CI was paused (2026-10-24, #173), then run only by hand, once per batch of five issues on a batch/ branch, with no tests on the developer's machine (2026-10-30, #204), then not at all while Apollo Download Manager was made ready to publish (2026-10-31). On 2026-10-31 the repository went public, so its minutes are free, and the triggers below came back (#236). The batch runs are cited in some PRs and code comments by their run numbers.

Tiers

#EventWhat runsPlatformsPurposeBuilt in
1pull_request into main (opened, synchronised, reopened)L1 for the selected areas, plus L2 for those areas. Docs-only changes run only the docs jobLinuxFast feedback before review. One aggregate check, ci-required, summarises it#13, #14, #91
2push to main (that is, after a squash merge)Selected like the PR tier, against the commit main was at before the push (main.yml). Linux: the L1 checks the merge needs (check:pr), then save the Rust cache. Windows: every L1 check (check:fast) when Windows-relevant paths changed (below). The docs and guidance checks always. A red run opens the tracking issue (below)Linux, WindowsCatch what the PR tier deliberately left out, on Windows too, before a release candidate is built from main, and keep the PR caches warm#14, #16
3workflow_dispatch on a commit, or a v*-rc.* tagEverything runnable (check:full and the L2 suites) plus installersLinux, Windows; macOS once #83 establishes itA downloadable, labelled release-candidate build from a verified main commit#17
4v* tag (not -rc)The release gate: it rechecks that the tag's commit passed tier 3, then builds artefacts with provenance into a draft releaseLinux, WindowsPrepare a release the PO publishes by hand#18
5schedule (weekly) and workflow_dispatchL4: everything on Linux and Windows. Later also fuzzing, stress and extended benchmarks. macOS on demand onlyLinux, Windows; macOS manualCatch slow drift and run what's too expensive per change#19

The website has a workflow of its own, site.yml: it builds the site on pull requests that change its sources, and builds and deploys it on main and when a release is published (WEBSITE.md).

No develop or staging tiers. All work merges into main, and releases are tags on verified main commits (DEVELOPMENT_WORKFLOW.md).

Required checks. The Main ruleset requires a pull request and linear history, and blocks deleting main and force-pushing to it (REPOSITORY_SETTINGS.md). ci-required becomes a required status check once PR checks have run on a pull request since they came back (#236): GitHub only offers a check it has seen. Until then:

  • "Required" means by convention: the PO merges only when ci-required is green (docs/REVIEWING.md).

  • The release gate is a person: only the PO pushes v* tags or starts a release run (an ask rule in .claude/settings.json stops the agent from doing it unprompted). The workflow creates a draft release, never a published one.

  • Next: the PO adds ci-required as a required status check in the Main ruleset (REPOSITORY_SETTINGS.md).

Selecting jobs

Selection extends the rules pnpm check:pr already uses, in scripts/checks-lib.mjs. The same code decides locally and in CI, so the two can't drift apart (#13).

  • Fail-safe: these select everything:

    • lockfiles (Cargo.lock, pnpm-lock.yaml);

    • native lock files anywhere, even inside a crate (*.lock, vcpkg.json);

    • shared configuration (Cargo.toml, package.json, rust-toolchain.toml…);

    • the check scripts (scripts/), .github/ and .claude/;

    • any path no rule knows, such as a package-lock.json, which Apollo Download Manager doesn't use.

  • Crates: a change inside a crate selects that crate and every crate that depends on it.

  • Frontend: frontend paths select tsc, ESLint and Vitest.

  • Docs only: docs/ and root *.md select only the docs job (pnpm check:links: relative links and heading anchors).

  • Native builds (#35): the Windows native job runs only for the native inputs (scripts/native/, crates/velocity-curl/, .cargo/, and native lock files other than Cargo.lock and pnpm-lock.yaml), for .github/, and for paths no rule knows. On Linux, the code job builds the native libraries itself before its Rust checks. Other shared files, such as Cargo.lock or another check script, don't select them: the builds take minutes, and Windows minutes bill double (BUILDING_NATIVE.md).

  • Windows-relevant paths (tier 2):

    • every Rust crate, src-tauri/, Cargo.lock, rust-toolchain.toml;

    • scripts/, since the check runner has Windows-specific code paths;

    • packaging configuration;

    • and, as always, unknown paths.

    Frontend-only and docs-only merges skip Windows, because a WebView difference is caught by the release-candidate tier instead.

  • Every selection prints its reasons in the job log, as check:pr does locally. A job that wasn't selected is reported as NOT RUN, never as passed.

Saving minutes never removes a relevant correctness or security test. When in doubt, the selector selects more.

How the PR tier applies it

The PR tier is implemented in .github/workflows/pr.yml (#13):

JobRuns whenWhat it does
selectAlwaysnode scripts/ci-select.mjs --base <PR base> writes rust, frontend, docs and native flags to its outputs. It adds a table to the run summary with every job, runs or skipped, the reason for each, and the reason for each changed path. When docs are selected, the same job runs node scripts/check-links.mjs as a step
select: product-name stepAlwaysnode scripts/check-product-name.mjs (#421, TESTING.md): no V2 for the product in what people read. Under a second, and a source change counts as much as a document, so it isn't selected by path
select: guidance stepAlwaysnode scripts/check-guidance.mjs (AGENT_GUIDANCE.md). Under a second, and any path can change its result (a deleted folder kills a rule's glob), so it isn't selected by path
codeRust or frontend selectedpnpm check:pr, the same command as locally. With Rust, scripts/ci-sftp-server.sh first installs OpenSSH if needed and starts the password-login SFTP server the SFTP tests use (#53). Tauri system packages and the pinned toolchain are installed only when Rust is selected. With Rust, check:pr builds the native libraries first (reused from the target/ cache that main saves), and the job uploads build-info-linux
native-windowsNative inputs, CI files or unknown paths changedOn windows-latest: node scripts/native/build.mjs (verifies every source, builds, checks curl -V against the lock file), uploads build-info-windows (7 days), then cargo test -p velocity-curl, which links the build and runs the start check and the TLS tests, and then the Windows certificate-store test (--test windows_store -- --ignored), which needs the runner's administrator rights (SECURITY.md, #69). No cache
ci-requiredUnless the run was cancelled (if: ${{ !cancelled() }})Passes only if select succeeded, and code and native-windows each succeeded or were skipped by the selection. It writes its own table to the run summary. A run superseded by a newer push is cancelled whole and reports nothing; always() would report a false failure there

Why so few jobs. GitHub bills every job's time rounded up to a whole minute (runner pricing), and every job repeats checkout and setup. So Rust and frontend share the code job, and the one-second docs check runs inside select. A docs-only PR then costs 2 billed minutes (select and ci-required), and a code PR costs the code job plus 2.

Skipped jobs and required checks. A job skipped by an if: condition reports Success, while a workflow skipped by path filters leaves a required check Pending forever (troubleshooting required checks). So the workflow never uses on.paths, and ci-required reports on every run that isn't cancelled. That keeps it usable as a required check if branch protection becomes available (#7).

Selection errors fail the run. If ci-select.mjs can't compute the change (for example, missing history), it exits non-zero rather than guessing, and ci-required fails.

Caching

The repository gets 10 GB of Actions cache, separate from artifact storage. GitHub evicts the least recently used entries above that (billing).

CacheKeyContentsSizeReasoning
Rust (Linux)OS + toolchain + Cargo.lock hashCompiled dependencies only (~/.cargo/registry, target/ without workspace crates)Measured in CI: 319 MiB as saved (1.1 GiB of target/ plus a 201 MiB registry before compression); locally 365 MiB with gzipA cold build is most of a cold job (measured: 571 s for the clippy and test builds on 2 vCPUs). Workspace crates rebuild in seconds, so caching them would only add upload time and size
Rust (Windows)SameSameAssumed: similar to Linux (not measured)Windows minutes cost more ($0.010 against $0.006 a minute), so a cache hit saves more
Native libraries (#205), per OSOS + hash of scripts/native/** (the build scripts, the lock file and the vcpkg manifest and ports)target/native: libcurl and its libraries, and libtorrent, Boost and OpenSSL from vcpkgNot measured yetBuilding Boost and libtorrent from source takes a large part of a job, so they are kept on their own, and saved even after a red run
pnpm store—Not cached147 MiB (Linux)pnpm install --frozen-lockfile took 4 s in CI (PR #104), less than a cache restore and save would cost (PO decision on #14)

Caching rules:

  • Budget: about 0.6 GB a platform when compressed, so Linux and Windows, with one previous key each during a lockfile change, use about 2.5 GB of the 10 GB.

  • Save on main only; pull requests restore. A PR branch's cache is visible only to that branch, so saving it there would fill the 10 GB with copies.

  • Keys change only when the toolchain or lockfile changes, so a cache uploads rarely.

  • No cache for Tauri system packages (apt): they installed in 35 s in CI (PR #104), about what restoring a cache of them would take.

How it's wired (#14):

  • Action: GitHub's own actions/cache/restore and actions/cache/save, pinned by SHA. No third-party cache action is used, because any third-party action is a dependency that needs PO approval (AGENTS.md).

  • Setup: the shared composite action .github/actions/setup restores the cache for every job that builds Rust. When the exact key is missing, it falls back to the newest older key (restore-keys), so a dependency bump starts mostly warm.

  • Saving: only main.yml, the Linux checks after each merge to main, saves the cache. It does so after a fully green run, when the exact key isn't cached yet, and after removing the workspace crates' artefacts.

  • The native libraries (#205): a composite action of their own, .github/actions/native, called by the setup action and by PR checks' Windows native job, restores target/native. Only when that misses does it set up vcpkg: a clone of microsoft/vcpkg at the builtin-baseline commit of scripts/native/vcpkg/vcpkg.json, bootstrapped with -disableMetrics, with VCPKG_DISABLE_METRICS=1. Every job that builds them saves them right after its checks, whatever their result, so a rerun on the same branch skips the build. A cold build of Boost and libtorrent is why the Rust jobs may run up to 90 minutes (Windows Main checks 120); a warm one takes far less. GitHub restores a cache only on the branch that saved it or from the default branch, so a new branch builds them once unless a run on main saved them.

  • Cache scopes (#82): the Linux packages workflow builds on Ubuntu 22.04, while the checks run on ubuntu-latest. Libraries built on the newer Ubuntu can need a newer glibc than 22.04 has, so its jobs pass the setup action's cache-scope input, and their keys read rust-v1-Linux-ubuntu22.04-… and native-v1-Linux-ubuntu22.04-…. It saves its native libraries as the Windows installer workflow does, one more native cache in the 10 GB (size not measured yet), and saves no Rust cache (PACKAGING.md).

  • Release candidates use no cache (#233): their packaging runs restore no Rust or native cache and save none (the setup action's cold input), so nothing an earlier run saved can reach an installer. CodeQL's cache-poisoning rules flagged the warm build. A candidate takes longer, about 30 to 60 minutes instead of 10 to 20 (an estimate from the cold native build of #205; not measured yet), and candidates are rare (PO decision, 2026-10-31). A packaging run started by hand still uses and saves the caches.

  • No incremental compilation in CI: CARGO_INCREMENTAL=0 keeps the cache smaller and CI builds faster.

Runner disk. A hosted Linux runner has about 14 GB free. A cold build of Boost and libtorrent plus check:fast's debug build filled it twice (#76), so the Linux jobs that build Rust first delete toolchains Apollo Download Manager never uses (.NET, the Android SDK, GHC and CodeQL), about 20 GB.

The post-merge tier

main.yml has four jobs (#16):

JobRuns whenWhat it does
selectAlwaysnode scripts/ci-select.mjs --base <github.event.before>: the same selection as a pull request, for what the merge changed. Also check:links, check:product-name and check:guidance
linuxRust or frontend selectedWith Rust, the SFTP test servers (scripts/ci-sftp-server.sh, #53). check:pr --base <before>, which runs the native build before any Rust check, then saves the Linux Rust cache when Rust ran and its key is new
windowsWindows-relevant paths changed (Rust, scripts/, CI files, unknown paths)The OpenSSH Server feature if the image lacks it (#53), then check:fast on windows-latest, with its own Rust cache (key rust-v1-Windows-<hash>). It doesn't depend on linux, so a Linux-only failure doesn't stop it (scenario B of #91)
summaryUnless the run was cancelledCombines the job reports into the run summary and ci-diagnostics, then opens, comments on or closes the tracking issue. It's the only job allowed to write issues

A docs-only merge runs only select and summary.

Manual runs: gh workflow run main.yml --ref <branch> [-f base=<commit>] [-f tracking-title="<title>"]. They're for trying a change to this workflow. Without base, a manual run checks everything. A tracking title is for test runs only: on main the title is always main is failing, so a test run never touches the real issue.

Packaging and release candidates

Three workflows are started by hand only, and run on no push or pull request, and a fourth by a release tag. They aren't checks:

  • Windows installer (package-windows.yml) and Linux packages (package-linux.yml) build and check the packages of the branch they are started on (PACKAGING.md).

  • Release candidate (release-candidate.yml) verifies a main commit, calls both packaging workflows for it (workflow_call, with the commit as ref), and assembles the candidate (RELEASE_CANDIDATES.md). A vX.Y.Z-rc.N tag starts it too. It needs actions: read, to find the commit's Main checks run.

  • Release (release.yml), on a vX.Y.Z tag: publishes an accepted candidate's files as a draft release, after the PO's approval in the release environment (RELEASE_PROCESS.md). Only its draft job may write (contents: write); assemble signs a provenance attestation (id-token: write, attestations: write).

  • Strict needs (#91): in these four, a job runs only when every job it needs succeeded. No job condition uses always(), !cancelled(), failure() or cancelled(), and nothing uses continue-on-error; tests read the four files (scripts/release-candidate-lib.test.mjs, scripts/release-lib.test.mjs). A step may still save a cache after a failed step, as the native libraries' does.

  • A new one can't be tried on a branch: GitHub starts a hand-started workflow only from a file already on main, so each is first run after it merges.

When main breaks

main must stay releasable (DEVELOPMENT_WORKFLOW.md):

  • Visible within the run: a red main run opens the issue "main is failing". It holds the failing checks with their first error, the run link and this policy (scripts/ci-main-status.mjs). While the issue is open, each further red run comments on it, and the next green run closes it.

  • The policy: fix forward with a fix/ PR, or revert the offending squash commit through a PR, within one working day (project judgment). The tracking issue is where that is coordinated.

  • A missing report counts as red: if a run leaves no ci-diagnostics report, the issue still opens, saying so. A broken run is never silent.

Cancelling and de-duplicating

  • Superseded PR runs are cancelled. Each workflow sets concurrency: { group: <workflow>-<PR or ref>, cancel-in-progress: true } for pull requests (concurrency). Runs on main and on tags are never cancelled: each commit's result matters.

  • No duplicate runs for one commit. Pull-request checks trigger on pull_request only, and push triggers only for main and tags. A branch push therefore doesn't run CI a second time beside its PR.

  • Path filters live in the selector, not in on.paths. Then the aggregate ci-required check always reports, instead of being missing when a workflow is skipped.

Permissions and supply chain

GitHub's guidance is in secure use:

  • Read-only by default. Every workflow declares permissions: contents: read, and a job raises only what it needs (for example contents: write for the draft-release job in #18).

  • Pinned actions. Third-party actions are pinned to a full commit SHA, with the version in a comment. GitHub's own actions/* are pinned the same way, for consistency.

  • Dependabot updates the pins: .github/dependabot.yml checks the workflows and the composite action monthly, grouped into one PR, so an update costs one CI run. Each update is an ordinary PR the PO reviews. For Cargo and npm, Dependabot opens security-fix PRs only, no routine version updates (PO decision on #75); other dependency changes need PO approval (#9).

  • No pull_request_target that checks out PR code. No secrets in PR workflows.

  • Untrusted text stays out of commands. Titles, branch names and other user-controlled strings never go straight into run: scripts; they pass through environment variables.

  • Third-party notices (#76): the Select jobs job of PR checks and Main checks runs pnpm check:notices whenever Rust is selected, which every lockfile change does, so a failure stops the run before the long checks. It fails on a licence outside the allowed list and on a THIRD_PARTY_NOTICES.md that no longer matches the dependency graphs (LICENSING.md). Once, on Linux: the graphs cover every target platform. Dependabot's PRs fail it, because Dependabot doesn't regenerate the file; how they're handled is in SECURITY.md.

  • Self-hosted runners: none. If one is ever added (PO decision), it never runs code from a fork.

Collecting failures

Every run completes every check that can still run, and reports all failures in one place (#91). The details are in CI_DIAGNOSTICS.md.

The quick checks come first (PO decision on #76, 2026-10-30). The docs, product-name, guidance and notices checks run in the select job and take about a minute. When one fails, the Linux and Windows jobs don't start, and the summary reports them BLOCKED by select. The run ends in about a minute instead of 30 to 40, so a broken link costs a minute to find, not a whole run. Fix it and run again; the code checks then run. Until #76, the code jobs ran even after a failed docs check, so that one broken link wouldn't hide every code failure (#91).

  • Checks run to completion:

    • The check runner runs every check even after one fails.

    • Clippy uses --keep-going and cargo test uses --no-fail-fast.

    • Report and summary steps use if: ${{ !cancelled() }}.

    • Mandatory checks never use continue-on-error, which would turn a failure into a success.

    • Cross-platform matrices (#16, #17, #19) use fail-fast: false.

  • Infrastructure problems are reported as INFRA, separately from code defects. That covers a failed checkout, toolchain install, cache restore or pnpm install, and a job that died before reporting. A check that couldn't run because its prerequisite failed is BLOCKED, with the prerequisite named.

  • One consolidated report. ci-required (PRs) and the main job publish a table in the run summary and the ci-diagnostics artifact (ci-diagnostics.json). The run fails unless every mandatory check passed or was deselected. The /ci-triage skill reads it.

  • Gated jobs stay strict. Packaging and release jobs (#17, #18) keep the default needs semantics, so nothing is built for release after a failed check.

L4 schedule

RunWhenContentPlatforms
Weekly fullMondays 03:00 UTC, only if main changed since the last runcheck:full, all L2 suitesLinux, Windows
Fuzzing, stress, extended benchmarksWeekly, staggered, once the suites exist (#73, #77)Time-boxed per target, with a fixed seed recordedLinux
macOSworkflow_dispatch onlycheck:fullmacOS (#83)
Advisories (advisories.yml, #75)Mondays 04:00 UTC, and by hand (workflow_dispatch)pnpm check:advisories: cargo-deny and pnpm audit against the lockfiles (SECURITY.md)Linux

macOS minutes cost about 10 times Linux ($0.062 against $0.006 a minute), so macOS never runs on a schedule or per change until it's an established platform and the quota allows it.

Costs

Measured locally (docs/DEVELOPMENT.md, 2026-10-21). A cold dependency build takes 278 s on Windows (16 logical CPUs) and 413 s on Linux (WSL2, 2 vCPUs, similar to a 2-vCPU hosted runner). A warm check:fast takes 5.5–9 s. A full check:full takes 224–249 s, of which 215–232 s is the release build.

Measured in CI (ubuntu-latest, 2 vCPUs, 2026-10-21). Every run of the PR tier and the first main run so far. Billed minutes are each job rounded up to a whole minute (runner pricing); a skipped job costs nothing.

RunChangeCacheWall timeJobs (s)Billed
39657529808PR #104, everythingnone542 sselect 14, code 519, docs 31, ci-required 412 min
39663435122PR #104, everythingnone566 sselect 21, code 536, ci-required 411 min
39666333757PR #105, everythingnone505 sselect 21, code 476, ci-required 310 min
39669208703main after #105, all L1, saved the cachenone453 slinux 4538 min
39670447026demo #106, docs only—32 sselect 25, ci-required 4 (code skipped)2 min
39670571690demo #107, one cratewarm113 sselect 12, code 92, ci-required 44 min
39670483912demo #108, Cargo.lock (everything)warm, via the fallback key181 sselect 22, code 150, ci-required 45 min
  • Medians:

    • Successful PR runs: 343 s (6 runs).

    • Cold PR runs: 542 s, 11 billed minutes.

    • Warm code PR runs: 113–181 s, 4–5 billed minutes.

  • A cold code job: about 1 min of setup (Node 7 s, Tauri packages 35 s, toolchain 9 s, pnpm install 4 s), then about 7.5 min of compiling (clippy about 280 s, tests about 140 s).

  • With the cache restored: clippy takes 2–3 s and the tests 3–6 s. The cache is 319 MiB (key rust-v1-Linux-<hash>, saved by main).

  • The rest is the 3 billed minutes of fixed cost per code run: select, the setup inside code, and ci-required.

  • Failure collection (#91) adds no billed minutes. A warm run with everything selected cost 4 billed minutes before and after it: 39671582041 (PR #109) against 39676944320 (PR #110). ci-required grew from 5 s to 17–19 s (checkout, Node, downloading the reports), still within its one billed minute.

Measured on the post-merge tier (#16, 2026-10-22; manual runs of main.yml on demo branches; Windows minutes count double):

RunChangeJobs (s)Billed
39775990305One crate, with a deliberate Linux-only test failureselect 24, linux 96 (failed), windows 1084 (cold, no Windows cache yet), summary 2342 min (Windows 38)
39778706769Docs onlyselect 28, summary 25 (linux and windows skipped)2 min

Still assumed (no runs yet):

JobCacheDurationBasis
Post-merge Linux, warmwarm4 min billedThe warm code job plus about 1 min of setup
Post-merge Windowswarmabout 10 min billedThe cold run measured above was 18 min; the warm figure waits for the first run after main saves the Windows cache
Release candidate (Linux + Windows, with installers)warm40 minTwo check:full runs plus bundling
Weekly full (Linux + Windows)warm30 minTwo check:full runs

Monthly estimate at the current pace. One agent, about 4 merged PRs a working day, so about 80 a month. About 3 CI runs per PR after cancellation; about a quarter of PRs are docs-only.

ItemRuns a monthBilled minutes eachMinutes a month
PR checks, code (measured 4–5, warm)60 PRs × 3 = 1805900
PR checks, docs-only (measured)20 PRs × 3 = 602120
Post-merge Linux804320
Post-merge Windows (Windows-relevant merges, assumed half; #16)3010300
Weekly full430120
Cold-cache penalty (new Cargo.lock or toolchain, assumed 8; cold main and PR runs)8+648
Cold Windows post-merge (new Cargo.lock or toolchain, assumed 8; measured 38 billed, about 28 above warm)8+28224
Release candidates (assumed 2)24080
Total≈ 2,110

That is slightly over the 2,000-minute quota once the measured cold Windows runs are counted (#16). The PR tier still dominates. Ways to bring it under:

  1. Push to the PR branch less often: batch fixes before pushing. Cancellation discards superseded runs (demonstrated on demo #107), but a started job still costs its minutes.

  2. Run Windows post-merge on a daily batch of the merged commits, instead of per merge. This saves up to 300 minutes, at the cost of slower Windows feedback.

  3. PO decisions: a payment method or plan change (then overage is billed instead of blocked), or making the repository public (standard runners are free; #90).

Windows minutes: the runner pricing page lists only dollar rates and states no multiplier against the included minutes. Until the first Windows run shows its effect on the usage report, budget Windows as if it counted double. Then the Windows line above becomes 600 minutes, and the total about 2,190, over the quota.

Artifact storage is 500 MB (shared with Packages). Retention is set per upload:

  • 7 days for diagnostics and logs;

  • 14 days for release-candidate installers;

  • releases go to GitHub Releases, which don't count as Actions artifacts.

The repository default is 90 days (retention) and is too long for 500 MB.

Watching the quota: check usage in the account's billing settings at least weekly while the CI tiers are new. #14 records the first measured month and updates the tables above.