Desktop Material

Supply-chain and CI hardening

Desktop Material's continuous-integration workflow builds the Windows installers that users actually run, so what its jobs install, how its runs are scheduled, and how it proves those installers remain unsigned are security properties, not conveniences. Dependabot proposes dependency updates instead of letting pins rot, every install in CI is pinned to the committed lock file, and a dedicated Supply chain job checks lock-file provenance and reports npm advisories.

Automatic CI Windows validation uses GitHub-hosted windows-2022 machines. A protected-main manual dispatch keeps the explicit cloud or self-hosted choice for the desktop build and packaged E2E jobs; the self-hosted choice maps only to [self-hosted, Windows, X64, desktop-material-windows-local]. Pull requests, pushes, and reusable calls cannot select that local pool. The Windows test/package jobs in Build Installers / Express Release, plus both jobs in the direct Windows Super Express workflow, use the same project-labelled self-hosted pool and its verified dependency and tool caches.

Replaceable Windows validation uses a workflow/event/ref concurrency group with cancel-in-progress: true; a newer same-event, same-ref validation may supersede an older one without letting a manual run cancel the push event that gates publication. Release workflows use stable non-cancelling groups, so a later dispatch cannot strand an in-flight tag or upload. Direct Super Express Windows packaging and publication both require [self-hosted, Windows, X64, desktop-material-windows-local]; publication does not depend on a second operating system or runner pool. The publisher requires the current main tip, stages a draft, verifies the exact target and all six non-empty Squirrel assets, then publishes a unique non-draft Release with make_latest=false. It records timing from the first Actions job's started_at through the completed publication step, writes and verifies those notes, and only then reconciles Latest. A same-job failure deletes the captured release ID and only the exact newly created tag before restoring the prior Latest. The write-capable release token is scoped to authenticated API steps rather than the publisher job. Its optional unique code name searches every published catalog-v1* photo volume without reusing a name when release history is unavailable. Reusable Super Express calls are accepted only from Ding-Ding-Projects/desktop-material on protected main.

Windows packaging is permanently unsigned in every release lane. The package steps explicitly disable certificate auto-discovery, clear Windows signing and Azure identity inputs, and never call a signer. After packaging, both the setup executable and MSI must report NotSigned; a present or attempted signature is a failed build, not a reason to discover or request a credential. The release notes state that the artifacts are unsigned and may trigger Windows SmartScreen or an unknown-publisher warning.

The release gates still fail closed on the exact commit, unique version and tag, filtered Squirrel RELEASES manifest, and all six required non-empty assets. A streaming verifier recomputes every package SHA-1 and byte size named by RELEASES both before artifact upload and after artifact download. The unsigned policy changes no integrity, source-target, or immutable-release check.

The Express release gate also distinguishes an ineligible completion from a failed eligible build. A workflow_run for a cancelled manual dispatch, a fork, or another non-publishable source writes proceed=false and exits successfully, so no installer or Release is created and the guard does not turn an intentional no-op into a red workflow. A completed main push from this repository still follows the strict CI-conclusion and sibling-lane checks below; an unknown lane or a failed eligible lane remains a real release-gate failure.

The fresh-install contract is checked against the repository's pinned toolchain: the parity generator and generated YAML must declare the same 206 desktop features, and the TypeScript configurations must remain valid for the pinned TypeScript 5.8.2 release. The dependency compatibility test guards those settings so a TypeScript 6-only option or a script root that cannot resolve repository imports fails locally before consuming runner minutes. The settings-tab migration map is intentionally consumed by persistence code in a class method; its lint annotation documents that ownership rather than hiding an unused property.

The composite dependency setup treats hosted CI and the remaining Linux and Windows self-hosted Super Express runners as first-class environments. It installs uv 0.11.26, provisions the pinned Python 3.11 interpreter with uv python install, exports the resolved interpreter to npm_config_python, and skips actions/setup-python's hosted tool cache whenever runner.environment is self-hosted. This matters on Debian 13, where the hosted Python manifest does not contain the requested 3.11 x64 entry; relying on that action would fail before the actual lint or package work starts.

Windows self-hosted setup restores the exact installed-dependency cache with an explicit actions/cache/restore step and saves a verified miss with an explicit actions/cache/save step before the build starts. The key includes both lock files and manifests, the post-install/setup/tool-bootstrap actions, pinned Yarn, local native-vendor sources, the target architecture, and the Node/Python versions. Older installed-deps-v5 and installed-deps-v4 caches may be restored only as a warm start; their non-hit status still forces the current lock files through the complete install. The cross-compilation install restores its package manifests before the exact cache key is saved, and both Windows toolset scripts participate in key invalidation. Self-hosted keys also include the runner identity and selected Visual Studio/MSVC, ClangCL, and Windows SDK fingerprint; the install uses yarn --frozen-lockfile. Hosted jobs retain the ordinary cache post step, while self-hosted jobs avoid an unbounded post-job archive hook. Build output, installers, Release assets, credentials, and runtime configuration remain uncached. The focused contract test checks both the runner selection and this restore/verify/save split.

The self-hosted Windows cold path also bootstraps release tools instead of assuming a prepared image. An initial archive-capable checkout obtains the bootstrap, the pinned PortableGit 2.53.0.3 archive is hash-verified, and a second checkout creates the real Git repository before any Git or Bash work. PortableGit, GitHub CLI 2.97.0, and jq 1.7.1 keep only canonical download bytes in versioned RUNNER_TOOL_CACHE paths. Those bytes are checked against repository-pinned platform hashes on every run, then copied or extracted to fresh job-local paths before execution. PortableGit supplies the curl, sha256sum, and unzip prerequisites needed by the remaining installers. A repeat run avoids network downloads without trusting a persistent executable; no step mutates an unrelated system-wide installation.

The hosted Windows E2E lane does not install a second system-wide FFmpeg package. The repository post-install step provisions Playwright's pinned FFmpeg payload, and the dependency-cache sentinel verifies that payload before a cache is used. Avoiding a Chocolatey install keeps the job reproducible and prevents a stale system package lock from blocking the entire E2E job.

Python 3.13 UI test process isolation

The Linux TUI matrix keeps Python 3.10 and 3.12 on the ordinary full-suite command. Python 3.13 uses tui/tools/run-tests-isolated.py: it discovers every test file, runs all non-UI files together, and launches each tests/ui file in its own interpreter. This is a test-process boundary only; no product feature or test assertion is disabled, and future UI files are included automatically.

The failure mode is a Python 3.13 native-process segfault after several app-heavy Textual files share one interpreter. The fatal trace can appear in a pure-Python stylesheet update while tree_sitter_json._binding is loaded, so retrying the same monolithic command is not a useful diagnosis. The safe recovery is to run the affected UI file in a fresh process and preserve the full result set. The runner uses the current locked interpreter and repository-owned paths only; it does not evaluate test paths from the network or accept shell fragments, so the isolation adds no remote execution surface. The committed unit contract verifies that the runner discovers all test files, keeps the UI/non-UI sets disjoint, and includes representative root and layout tests. Local WSL verification passed 574 non-UI tests and 99 UI tests with the boundary; the remote Python 3.13 result remains the release gate.

The self-hosted Linux Super Express lanes also bootstrap the pinned GitHub CLI when the runner image does not provide gh. The bootstrap reuses an existing CLI or downloads the canonical Linux archive into RUNNER_TEMP, verifies its published SHA-256 checksum, and adds only that temporary bin directory to GITHUB_PATH. Release-gate API calls therefore do not depend on a cloud-runner convenience package or a system-wide install. The sibling-run lookup also uses the CLI's built-in --jq evaluator and returns one tab-separated record, so it does not silently assume that a separate jq executable happens to be installed on the self-hosted image. This keeps the dependency surface explicit: the only release API binary that must be bootstrapped is the pinned gh archive whose checksum is verified above.

Behaviour

Dependency update proposals

.github/dependabot.yml covers three manifests:

Ecosystem Directory Schedule Open-PR limit
github-actions / Weekly Monday 3
npm (Yarn 1) / Weekly Monday 5
npm (Yarn 1) /app Weekly Monday 3

Before this was added, the file contained only the github-actions entry with open-pull-requests-limit: 0, which disables version updates entirely: no update was ever proposed for the workflows, and neither the root toolchain nor the packaged app's own dependencies were covered at all.

Updates are grouped so the queue stays readable. Each npm entry raises one pull request for development-dependency minors and patches, and one for production patches; production minors and every major arrive individually because they deserve individual review. Action updates arrive as a single grouped pull request, since each one is a change to the pipeline that publishes releases.

The limits are small on purpose. Every pull request against this repository runs the full CI matrix — a Windows x64 and arm64 build, a packaged E2E smoke run, and the Python TUI matrix — so an open-PR limit is really a budget for reviewer attention and runner minutes. Five at the root leaves room for the two groups plus a few individual majors; three for app/ is deliberately tighter because those dependencies ship inside the installer.

versioning-strategy: increase-if-necessary keeps a ^-style range in package.json untouched when the new version already satisfies it, so most proposals are lock-file-only changes.

Some dependencies are ignored because a bot cannot update them correctly:

Not covered, deliberately: the file: dependencies in vendor/ (desktop-trampoline, desktop-notifications, windows-argv-parser, printenvz), because Dependabot cannot propose updates for a path dependency; and tui/uv.lock, which is outside this repository's current desktop-only work and would only produce pull requests nobody is allowed to act on.

Lock-file enforcement

Every CI install now resolves exactly what the committed lock file says.

With Yarn Classic, a plain yarn silently re-resolves and rewrites yarn.lock when it no longer matches the manifests. --frozen-lockfile makes that an explicit failure at the install step instead of a mystery later: the install stops with "Your lockfile needs to be updated, but yarn was run with --frozen-lockfile". In the lint job that fails earlier, and names the cause, where the existing git diff --name-status --exit-code guard would only report that some file changed at the end of the job. Yarn Classic's message is generic and does not name the drifted dependency — running yarn install locally shows which one it is.

The remaining nested and release installs are covered too:

On self-hosted Windows arm64 Super Express jobs, the setup action also discovers Visual Studio with vswhere.exe, reads Microsoft.VCToolsVersion.default.txt, and installs the Microsoft.VisualStudio.Component.VC.Tools.ARM64 and Microsoft.VisualStudio.Component.VC.Tools.x86.x64 components when the exact default MSVC version lacks VC\Tools\MSVC\<version>\bin\Hostx64\arm64\cl.exe. The arm64 helper runs before ClangCL, waits up to 120 five-second checks for the compiler after a quiet installer return, and exports the selected instance in npm_config_msvs_version so the later ClangCL check cannot silently choose a different installation. The action handles a runner where the MSVC directory is absent, verifies the compiler after installation, and fails before production build if setup did not complete. The hosted installer smoke test starts Setup.exe without PowerShell's descendant-inclusive -Wait, waits at most 300 seconds for that installer process, and terminates its process tree if the bound expires. It records pre-existing GitHubDesktop process IDs and the installer session, then repeats a scoped cleanup while polling for the exact package version's newly written executable. This prevents a Squirrel-launched app from holding the step open, does not kill an unrelated process that predated the test, and prevents a stale installation from being accepted.

All self-hosted Windows jobs also verify the architecture-specific ClangCL toolset required by the vendor/desktop-trampoline native test. The setup action selects one Visual Studio instance with the matching Toolset.props, Toolset.targets, MSBuild\Current\Bin\MSBuild.exe, x64 MSVC compiler, and VC\Tools\Llvm\<architecture>\bin\clang-cl.exe, then exports that instance through npm_config_msvs_version so node-gyp does not choose a different incomplete installation. When no complete instance is available, it asks the installed Visual Studio instance to add both Microsoft.VisualStudio.Component.VC.Llvm.Clang and Microsoft.VisualStudio.Component.VC.Tools.x86.x64 with the installer's supported quiet/no-restart flags. Because that invocation can return before the installer has materialized the files, the script then polls for the compiler and both MSBuild toolset files for up to 120 five-second checks before failing with an explicit setup error. Installer status codes 0, 3010, 1001, and 1618 remain eligible for that bounded verification; other codes fail immediately. The installer has no --wait option on the runner's Visual Studio Installer 4.7.25, so the setup contract rejects that unsupported flag. This keeps the native test from racing an in-progress installation while still failing closed when the runner never receives a usable toolset.

Lock-file provenance and integrity (blocking)

The supply-chain job's first step reads yarn.lock and app/yarn.lock and fails the job if either:

At the time this was written both files are clean: 906 resolved packages in yarn.lock and 324 in app/yarn.lock, all from registry.yarnpkg.com, all carrying an integrity hash.

Entries with no resolved line at all — the file: resolutions pointing into vendor/ — are skipped, because their content is reviewed as repository code. vendor/desktop-trampoline/yarn.lock is upstream's own file and is not checked: three of its 145 entries (balanced-match, isexe, safer-buffer) predate integrity and would fail the check without anything being wrong in this repository.

Dependency advisories (reporting, never blocking)

The job's second step runs yarn audit for the root and app/ manifests and writes both reports into the run's job summary, adding a ::warning annotation when advisories are found. It always exits 0.

That asymmetry is the point. yarn audit exits with a bitfield of the severities it found (1 info, 2 low, 4 moderate, 8 high, 16 critical), so gating on its exit code fails every commit for as long as an advisory exists — including a transitive advisory with no published fix, which no commit in this repository can repair. At the time of writing, yarn audit in app/ reports exactly that case: one high advisory for ansi-html, reached through webpack-hot-middleware > ansi-html, patched in >=0.0.8 upstream but not yet released through that dependency chain. A blocking audit would have turned CI red on arrival and stayed red.

So the split is — blocking, because each is deterministic and offline:

Reporting only, because neither is something a commit can be sure of fixing:

The "could not run" case is reported explicitly rather than passing quietly, because a silent empty audit looks identical to a clean one. The step distinguishes the two by looking for Packages audited in the output, not by the exit code, which is ambiguous between "found advisories" and "failed".

Run concurrency

Release and publication work retains non-cancelling run or ref groups. Windows validation is deliberately replaceable:

Workflow family Group cancel-in-progress
Windows push/pull-request/reusable validation Per workflow and ref Yes
Windows protected-main manual validation Per workflow and ref Yes
Linux CI Per run and attempt No
Tested Express Release Per run and attempt No
Pages publication Per run and attempt No
Super Express emergency release Per ref No

A newer Windows commit cancels stale validation on the same ref but cannot cancel a release. The registered Windows pool is available only to an explicit protected-main manual choice or to the Windows release jobs enumerated in the hand-written self-hosted inventory. The workflow safety test checks the exact labels, cloud fallback, protected-ref gate, direct build/publisher placement, and the non-cancelling release contracts.

Security considerations

Failure modes

error Your lockfile needs to be updated at an install step. A manifest changed without its lock file. Run yarn install locally and commit the updated yarn.lock.

Lock file provenance error annotation. A resolved URL points somewhere unexpected, or an integrity line is missing. Review the yarn.lock diff before merging. A legitimately new registry has to be added to the allowlist in ci-linux.yml deliberately.

Warning annotation "Dependency advisories". yarn audit found advisories. Read the job summary; upgrade if a fix exists, otherwise record the accepted risk. CI stays green either way.

Warning annotation "Dependency audit unavailable". yarn audit could not complete. Treat that run as carrying no advisory evidence and re-run once the registry is reachable.

A Windows CI validation says "Canceled". Check whether a newer run on the same ref replaced it. The cancelled run has no verdict and creates no release; use the replacement's exact SHA rather than treating cancellation as success.

A Super Express run says "Canceled". Release workflows do not cancel one another. Inspect a manual cancellation, runner loss, or provider interruption, then rerun the exact commit without reusing its immutable tag.

The Windows TUI job reports Filename too long while writing profile history. Both the checkout and the app-owned profile-history repository need core.longpaths. Confirm the workflow's repository-local setting runs immediately after checkout and that GitProfileHistory configures its isolated repository before it stages files, then rerun the exact hosted CI commit.

A Super Express Windows arm64 setup reports a missing MSVC toolset. The self-hosted setup discovers the installed Visual Studio instance, installs its arm64 C++ components when absent, and verifies Hostx64\arm64\cl.exe before node-gyp runs. If installation cannot complete, the job reports that setup failure and skips the production build instead of emitting misleading missing module errors from a build guarded by always().

A Super Express Windows x64 job stalls in desktop-trampoline native tests. Check the setup log for MSB8020 naming ClangCL. The shared self-hosted setup now selects and exports a complete architecture-specific ClangCL instance before the test; a failure to install it is a setup failure, not a test timeout to ignore.

A self-hosted Windows release job cannot find Git Bash, GitHub CLI, or jq. The job should enter the cold-bootstrap path. PortableGit, GitHub CLI, and jq are downloaded only from their canonical release locations, verified against pinned or published SHA-256 values, and stored below RUNNER_TOOL_CACHE. A missing checksum, mismatch, extraction failure, or absent executable stops the job at setup instead of allowing a later release command to fail opaquely.

A packaged setup executable or MSI reports a signature. Windows release jobs require Get-AuthenticodeSignature to return NotSigned for both files. Any other status fails the lane. Do not add credentials or restore signing; inspect which packaging input or tool attempted to sign the artifact.

A cancelled Windows or release run keeps working. Recovery steps intentionally use always() so a genuine test failure can still leave a diagnostic installer, but every heavy recovery, packaging, artifact-upload, and release-publisher condition also checks !cancelled(). A newer Windows validation may stop stale validation work; a newer release dispatch queues instead of cancelling publication.

Verification

Performed on 2026-07-31 against the working tree, before any push:

ClangCL bootstrap verification performed on 2026-08-06:

Current runner-selection and direct-release verification is also pending. The local contract proves that untrusted and automatic CI stays hosted, only a protected-main manual dispatch can select the exact Windows labels for ordinary CI, direct Windows build and publication use the fixed project-labelled pool, replaceable validation cancels by workflow/event/ref, and release publication never cancels. Run 31141543370 reached the old non-fatal submodule step but failed because Windows PowerShell 5.1 cannot parse Bash || true; the current step-level continue-on-error contract removes that wrong-shell fallback and contains a real submodule failure without hiding its outcome. The Windows unit workflow must leave worker memory to script/test.mjs, and the named installer step must contain exactly one bounded WaitForExit(300000), timeout tree cleanup, and repeated same-session/pre-existing-PID-scoped application cleanup. No remote green result is claimed for that working-tree repair yet.

Not verified locally, and not verifiable without a run on GitHub: