Skip to content

Update architecture

Status: partly implemented. Signed updates work for NSIS, AppImage and macOS, with rollback for NSIS and AppImage, and the package repositories for deb and rpm are built and tested; the release pipeline publishes every release. See implementation status.

Astela is designed to update itself on every supported platform without dialogs. A signed feed decides what gets installed. Depending on the package format, electron-updater, the operating system’s package manager or the IT department decides how it gets installed. The decision records are ADR 0014 (update architecture with a signed feed) and ADR 0015 (Linux package repositories); see the decision record index.

flowchart LR
REL["GitHub Release: packages, blockmaps, SHA256SUMS"] --> PAGES
PAGES["GitHub Pages feed: updates/v1/index.json and releases/{version}.json, each with .sig"] --> SRC
MIR[("Organization mirror, set by the UpdateURL policy")] --> SRC
SRC["IManifestSource"] --> VER["Ed25519Verifier with public keys built into the binary"]
VER --> POL["Update policy: off, automatic or manual; channel; version pin"]
POL --> EU["electron-updater in main: download, delta, SHA-512"]
EU --> INST["IUpdateInstaller per package format"]
INST --> BG["BootGuard and rollback"]

The update service belongs to the updates core module, which is always active (see modules). It runs in the browser process; the overview describes the process model.

Every link in the chain verifies the next one:

  1. The Ed25519 signature of the index, which references the per-version manifests.
  2. The Ed25519 signature of the manifest, which lists the SHA-512 hash of every file.
  3. The SHA-512 hash of the downloaded file.
  4. Authenticode on Windows (once releases are signed) or the code signature on macOS.

This chain also closes a known weakness of electron-updater 6: its own latest.yml metadata is not signed (as of October 2026).

The feed is a deliberately small design inspired by The Update Framework (TUF). It is designed to protect against tampering, rollback and freeze attacks without additional infrastructure.

updates/v1/index.json lists the releases per channel. Besides the release entries, it carries three fields that protect clients against stale or replayed data:

Field Meaning
sequence Monotonic counter. A client never accepts a value lower than the last one it saw.
issuedAt Time of signing.
expiresAt issuedAt plus 30 days.

Each version has a manifest, releases/<version>.json, with its own signature. It lists the artifacts per operating system, architecture and package format with file name, size, SHA-512 hash and blockmap. All file references are relative to downloadBase.

  • The default downloadBase is https://github.com/Secoolioo/astela/releases/download/v<version>/.
  • The UpdateURL policy points the client at a mirror instead. Because all references are relative, the signatures stay valid on the mirror.
  • The index and every manifest have a detached Ed25519 signature (.sig).
  • Verification uses Node’s built-in crypto.verify in the main process. There is no extra dependency, and a verification takes only microseconds.
  • The binary embeds two public keys: the current key and its successor, so that keys can rotate without breaking installed clients.
  • The private keys are stored only as GitHub environment secrets and in an encrypted offline backup kept by the repository owner. The rotation procedure will be documented with the release pipeline in the maintainer workflow.

The client will reject a feed or manifest when:

  • the signature is invalid,
  • sequence is lower than the last value seen (rollback attack),
  • expiresAt lies in the past (freeze attack),
  • the update would be a downgrade and no policy allows it.
  • The pages.yml workflow will re-sign the feed every week with a fresh expiresAt. Re-signing is a deployment, not a commit.
  • An expired feed fails safe: nothing will be installed. The status “Feed out of date” will appear on the About page and on app://diagnostics, and verify-live fails when the published feed is close to its expiry.
  • GitHub pauses scheduled workflows in public repositories after 60 days without activity (as of October 2026). The maintainer workflow will document how to re-sign manually with workflow_dispatch.
  • Mirrors must synchronize at least every 14 days.
  • Binaries will be downloaded from GitHub Releases, which has no bandwidth limits (as of October 2026).
  • The small feed lives on GitHub Pages.
  • There is deliberately no second, immutable feed as a release asset. It would expire and would conflict with the feed on Pages.

Astela uses electron-updater 6.8.x as the installation engine. It runs in the main process with asynchronous I/O and a custom provider (provider: 'custom'):

  • The SignedFeedProvider class will implement getLatestVersion() and resolveFiles() and will only return data taken from verified manifests.
  • electron-updater will handle the download, the blockmap delta, the SHA-512 check and the installation for the formats it supports.

The update domain is built from these interfaces:

Interface Responsibility
IUpdateService check(), download(), installOnQuit() and the current state
IManifestSource fetch(channel) returns the signed manifest from GitHub Pages or a mirror
ISignatureVerifier verify(bytes, signature) checks Ed25519 signatures against the embedded keys
IUpdateInstaller one strategy per package format: canHandle(installType), stage(artifact), apply()
BootGuard beginBoot(), markHealthy() and needsRollback() for the first starts after an update

The updater is a state machine: idle → checking → downloading → staged → applying.

  1. Check: 30–60 seconds after start, outside the startup path, then every 4 hours with ±30 minutes of jitter, and only while online.
  2. Download: in the background, as a delta where the format allows it, with full verification.
  3. Notify: a subtle dot on the menu button, a “Restart to update” menu entry and the status on the About page.
  4. Install:
    • silently when the user quits normally, or
    • during idle time with a silent restart and session restore, when all of these hold: the update has been ready for at least 2 hours, the system has been idle for at least 30 minutes, and no audio or video is playing, no download is running, no WebRTC session is active and no private window is open.
    • If the system is shutting down, no installation starts. It runs at the next start instead, before the first window opens.
  5. Afterwards: a non-modal “What’s new in vX.Y” note with a link to the changelog.
  • There are two channels: stable and beta. The beta channel also receives stable versions.
  • Switching from beta to stable never downgrades. The installation will stay on its version until a stable release overtakes it.
  • Prereleases are versioned X.Y.Z-beta.N. The maintainer workflow defines the versioning rules, and the roadmap lists the milestones.

Organizations will control updates with these policies (see enterprise and settings, policies and themes):

Policy Effect
UpdatePolicy 0 = off, 1 = automatic, 2 = manual only
TargetChannel Selects the channel.
TargetVersionPrefix Selects the highest version in the index that matches the prefix.
UpdateURL Points the client at a mirror.
AutoUpdateCheckPeriodMinutes Sets the check interval.
  • UpdateURL is a sensitive policy. It will be honored only from machine-level policy (HKLM on Windows, forced preferences on macOS); otherwise app://diagnostics will show a warning.
  • From M5, RollbackToTargetVersion and an update maintenance window follow.

tools/mirror is designed to mirror the feed and the packages one to one onto an internal HTTP server. The signatures stay valid because all URLs in the feed are relative. A mirror must synchronize at least every 14 days.

Updates must never destroy data. Every data migration takes a snapshot first, and an older version never opens a newer schema for writing (see data). On top of that, the BootGuard is designed to roll back a version that fails to start:

  • Before an update is applied, the old, working version will write boot-state.json with { pending: vNew, previous: vOld, attempts: 0 }. At that point the installation package of the running version will be available locally, either kept from the last update or downloaded and verified in the background beforehand.
  • The first lines of the new version’s main process will increment attempts. The new version counts as healthy once the shell is drawn, the profile database is open and it has run for 10 seconds without crashing.
  • After 2 failed starts, the BootGuard will:
    1. restore the previous version silently from the local package, without network access,
    2. restore the data snapshots taken before the migration,
    3. block the failed version locally until a newer version appears.
  • If a version fails to start without a preceding update, Astela will start in safe mode instead (manually in M1, automatically from M2).

Documented limit: the BootGuard cannot detect crashes that happen before the first JavaScript runs, for example with a damaged binary. The package smoke tests in every release run are the safeguard against this (see testing).

Format First install Update Delta Silent Rollback
Windows NSIS (per user, default for home users; x64, arm64) astela-windows-<arch>-setup.exe, no administrator rights electron-updater NsisUpdater through the signed feed yes (blockmap) yes, without a UAC prompt reinstall the previous version from its verified installer
Windows MSI (per machine, organizations; x64; stable only) msiexec /i … /qn with ALLUSERS=1 and a fixed UpgradeCode managed by IT; the in-app updater is off no yes, through IT tooling by IT, with ALLOWDOWNGRADE
macOS (dmg to install, zip to update; separate x64 and arm64 builds) open the dmg and drag the app to Applications electron-updater MacUpdater (Squirrel.Mac); needs a stable code signature, self-signed for now yes (zip blockmap; the first update after a dmg install is full) yes APFS clone of the current bundle, swapped back by a helper
Linux AppImage (x86_64, arm64) download, mark as executable; static runtime, no libfuse2 needed electron-updater AppImageUpdater replaces the file in place yes (blockmap) yes .previous copy, swapped back immediately
Linux deb (amd64, arm64) apt install ./astela-linux-<arch>.deb Astela APT repository, together with system updates; in-app updater off no yes, with system updates apt install astela=<previous>; the repository keeps 2 versions
Linux rpm (x86_64, aarch64) dnf install with the downloaded .rpm file Astela DNF repository, together with system updates no yes dnf downgrade
  • NSIS is the default for home users: a per-user installation without administrator rights, for x64 and arm64.
  • Before it installs or uninstalls, the installer closes a running browser of the current user with tasklist and taskkill (app/build/installer.nsh). electron-builder’s own check starts PowerShell and queries WMI; on fresh Windows runners that hung for up to 30 minutes, and companies often restrict PowerShell.
  • nsis with a separate blockmap is used. In the validation run, an update of a 113 MB installer downloaded 0.78 MB, so nsis-web is not needed.
  • Windows installers ship unsigned for now, so the first installation triggers a SmartScreen warning; updates run without it. The solution is Windows code signing (see code signing).
  • The MSI is a native, per-machine package for organizations. It is built for x64 and for stable releases only, because MSI ignores prerelease identifiers.
  • The installation type is recorded outside the policy keys, in HKLM\SOFTWARE\Astela\Install with Type=msi. Astela will detect this marker and turn the in-app updater off. IT distributes new MSI packages (Group Policy, Intune, SCCM) and controls updates with UpdatePolicy.
  • electron-updater cannot install MSI packages (as of October 2026). An optional elevated update task running as SYSTEM, which installs verified MSI packages silently in the way Chrome and Edge update, is planned for M5.
  • For arm64, electron-builder produces an MSI marked as x64 (as of October 2026). A dedicated WiX template follows in M5; until then, arm64 devices use NSIS.
  • Silent installation flags, properties and Intune detection are described in enterprise.
  • x64 and arm64 get separate builds. The dmg is for the first installation; the zip is for updates.
  • If the app runs from somewhere other than Applications (App Translocation), Astela will show a notice.
  • Squirrel.Mac requires a stable code signature across versions.
  • With an Apple Developer ID, the app is signed and notarized, and updates will install silently.
  • Without an Apple Developer ID, there are two paths:
    1. A self-signed certificate with a stable designated requirement, so that Squirrel.Mac and the Keychain keep working across updates. A CI updater test on macOS verified this path. The first start then needs a one-time “Open Anyway” in System Settings (macOS Sequoia and later).
    2. Fallback, not needed after the validation: the update is downloaded and verified in the background and installed with one click from the menu.
  • Current decision: there is no code signing budget for now, so macOS uses path 1. The Apple Developer Program (99 USD per year, as of October 2026) would add notarization and remove the Gatekeeper hurdle; once its secrets exist, the release workflow will sign and notarize automatically.
  • The AppImage uses the static runtime, so libfuse2 is not required.
  • Ubuntu 24.04 and later restrict unprivileged user namespaces, which disables the Chromium sandbox for AppImages (as of October 2026).
  • Astela will never fall back to --no-sandbox silently. The launcher that electron-builder generates for AppImages does exactly that (validation results), so Astela’s AppImage ships its own launcher. It starts the browser with the sandbox; only if Chromium refuses to start for lack of a sandbox does it open a local page with a one-click fix and the recommendation to use the deb or rpm package, in a restricted mode without web content.
  • The fix will use pkexec to install an AppArmor profile with userns. Because AppImages run from /tmp/.mount_*, the profile applies to /tmp/.mount_*/astela; this scope will be documented for users.
  • “Continue without sandbox” will be available only after explicit confirmation and will keep a permanent warning bar.
  • The fix is tested on a host runner with the user namespace restriction enabled, not in a container. The validation run confirmed on Ubuntu 24.04 (x64 and arm64) that the profile gives the AppImage a working namespace sandbox.
  • Astela will publish its own APT repository on GitHub Pages with a signed InRelease file and two suites: stable and beta.
  • postinst sets up the repository source once (deb822 format with Signed-By), on the beta channel for a beta package and on the stable channel otherwise. An environment variable allows opting out, another one points the source at a mirror.
  • Beta packages are versioned 0.1.0~beta.N, so they sort correctly before 0.1.0.
  • Updates will arrive with the regular system updates. The in-app updater is turned off through package-type.
  • electron-updater’s deb updater asks for a password and does not verify a GPG signature (as of October 2026), so it is not used.
  • The deb package will enable the sandbox through an AppArmor profile with userns (electron-builder template), with the SUID helper as a fallback.
  • GitHub Pages has a 1 GB size limit and a soft limit of 100 GB of traffic per month (as of October 2026). The repository will therefore keep only the last 2 versions; if it outgrows that, the package pool moves to other hosting.
  • Astela will publish its own DNF repository. The metadata will live on GitHub Pages; the packages will be served directly from GitHub Releases through createrepo_c --baseurl (xml:base, supported by dnf5).
  • The metadata is signed (repomd.xml.asc) and the packages are signed with rpmsign.
  • %post creates /etc/yum.repos.d/astela.repo once; a beta package (0.1.0~beta.N) points it at the beta channel, which also receives stable releases. The build writes the channel into the package, because rpm cannot query its database while it installs a package.
  • On an rpm upgrade, the removal script of the old package runs after the install script of the new one. It therefore removes the command link, the AppArmor profile and the repository only when the package is removed for good. Packages up to 0.1.0-beta.2 still removed them on an upgrade; a %posttrans script, which runs after all others, sets the command link and the profile up again. The linux-repos job checks that the command survives the upgrades between its test packages and that the package scripts print nothing; the live update proof checks the command after the upgrade from the previous release.
  • Fedora and RHEL will be tested; openSUSE is supported on a best-effort basis.

The upgrade sequence 0.1.0-beta.1 → 0.1.0-beta.2 → 0.1.0 must work for NSIS, AppImage, deb (0.1.0~beta.N) and rpm. MSI packages are built only for stable versions. The linux-repos CI job tests this order for deb and rpm with test versions, and after each release, the live update proof tests the step from the previous release on real installations (see testing).

Release assets carry no version in their names, for example astela-windows-x64-setup.exe, astela-macos-arm64.dmg and astela-linux-x86_64.AppImage. They will be attached to versioned tags, so releases/latest/download/<name> stays a stable link. The blockmap delta will find the previous version through its tag.

These spikes ran at the start of M1, before the first walking skeleton release, because they carry the highest risk. The validation results record what was measured:

  1. Custom provider with Ed25519 verification, with NSIS deltas on Windows and AppImage deltas on Linux; delta sizes are measured.
  2. macOS with a self-signed certificate: Squirrel.Mac updates and Keychain stability.
  3. AppImage sandbox fix on Ubuntu 24.04 and 26.04, on a host runner with the user namespace restriction enabled.
  4. APT and DNF repositories: installation, upgrade and the version order ~beta.N → stable. Containers test the package logic; the host tests the sandbox.
  5. node:sqlite in the utility process with FTS5, WAL and VACUUM INTO on three operating systems and two architectures.
  6. Inventory of the web platform APIs exposed by the embedded Chromium in the packaged build (see security).
  7. Moving a tab between windows without a reload on Windows, macOS, X11 and Wayland.

Code signing is designed into the release workflows from the start. For now there is no code signing budget: macOS releases use the self-signed certificate path described below, and Windows releases ship unsigned. Signing steps will run only when the corresponding secrets exist; without certificates, everything will be built unsigned. Once the secrets are added, releases will be signed automatically.

  • Without an Apple Developer ID, a self-signed code signing certificate valid for 10 years keeps the designated requirement stable. The repository owner generates it locally with tools/owner/setup.sh.
  • Secrets for a Developer ID:
    • CSC_LINK and CSC_KEY_PASSWORD: the Developer ID Application certificate as .p12.
    • APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER: an App Store Connect team key.
  • Switching from the self-signed certificate to a Developer ID breaks the designated requirement once. Existing installations will therefore receive that one update through a guided one-click step.
  • Signing, notarization and stapling of the app and the dmg will run automatically.
  • The Hardened Runtime uses minimal entitlements: JIT, camera and microphone, with usage descriptions.

For now, Windows packages will ship unsigned. The release workflow is designed to support three options and to sign automatically as soon as the secret for one of them exists; the secret that is present selects the option:

  1. SignPath Foundation: free for open-source projects after an application; the certificate is issued to “SignPath Foundation”.
    • The repository owner approves each release with one click in the SignPath interface. The release job will wait up to 24 hours for that approval.
    • Betas may be published unsigned. Fully automatic signing, including within the 24-hour target for critical security fixes, requires option 2 or 3.
  2. Azure Artifact Signing: about 9.99 USD per month; for individuals only in the USA and Canada, for organizations also in the EU (as of October 2026).
  3. Astela’s own OV certificate in a cloud HSM.

With every option, publisherName is fixed, and the updater will check it fail-closed.

  • A GPG key signs the APT and DNF repositories.
  • The Ed25519 update key signs the feed and SHA256SUMS.

See security for release integrity and the supply chain measures.

The updater tests are designed to use a local test feed on 127.0.0.1 with two builds (X-test.1 → X-test.2) and a test key. The mirror is set through the real policy mechanism, so production builds need no test backdoor. Negative cases cover a wrong signature, a hash mismatch, a downgrade and an expired manifest; a rollback case starts a build with a deliberate boot failure. After every release, an installation of the previous release proves the real path: in its normal configuration, it updates itself to the new release through the live feed, and its deb and rpm packages upgrade through the live repositories. Details are in testing.

Every package format and architecture in the table above is built and smoke-tested on every pull request (app/electron-builder.config.mjs), with the versionless asset names. The packages are unsigned so far.

The update path for NSIS, AppImage and macOS is implemented:

  • Signed feed (app/src/domain/updates/feed.ts): index and manifests are verified before they are parsed, with the rejection rules above (signature, sequence, expiry, no downgrade), the channel and the version prefix.

  • Keys: the public keys are read at build time from app/build/keys/update-*.pub.pem. A build without keys cannot verify the feed and therefore never updates. The release keys are committed; SECURITY.md lists their fingerprints.

  • Engine (app/src/platform/updates/): the SignedFeedProvider for electron-updater. A check reads the signed feed first and loads electron-updater only when it finds an update, so the checks every 4 hours cost one small download and a signature check. Downloads use the blockmap delta and the SHA-512 from the signed manifest.

  • Service (app/src/domain/updates/update-service.ts): the first check 30–60 seconds after start, then every 4 hours with ±30 minutes of jitter, a retry while offline, and the installation while the browser exits after a normal quit.

  • Policies: UpdatePolicy, TargetChannel, TargetVersionPrefix, UpdateURL (machine-level only) and AutoUpdateCheckPeriodMinutes.

  • Installation types: MSI (a per-machine installation under Program Files), deb and rpm turn the in-app updater off. The registry marker described above replaces the path check when the MSI properties land.

  • Feed tool (tools/feed/feed.ts): writes the manifest of a release from electron-builder’s update information of all builds, signs the manifests of all published releases with a fresh index, signs files such as SHA256SUMS, and serves a feed locally for tests.

  • Release pipeline (release.yml, pages.yml): attaches the manifest to every release as update-manifest.json. Every Pages deployment signs all manifests again with a new index whose sequence is the current Unix time and which is valid for 30 days; the weekly run renews it. See ADR 0016.

  • Boot guard (app/src/domain/updates/boot-guard.ts, app/src/main/boot.ts): the working version records an update before it installs it; the new version counts its starts right after the single-instance lock and counts as healthy after 10 seconds with a loaded shell or a normal quit. At the third start after two failed ones, the previous version comes back and the failed one is blocked. The state lives in the per-user astela-updater folder, so it belongs to the installation, not to a profile.

  • Rollback (app/src/platform/updates/rollback.ts): an AppImage keeps a copy of itself and swaps it back; an NSIS installation fetches the installer of the running version after an update was downloaded, verifies it against the signed manifest and reinstalls it silently.

  • Tests: unit tests for versions, the feed, the service and the boot guard; the updater CI job installs build 1 on Windows (NSIS) and Linux (AppImage), checks that it refuses a tampered feed, lets it download build 2 from a valid feed through the UpdateURL policy, quits it and starts build 2. Build 2 then installs build 3, which fails to start on purpose; after two failed starts, the third start must bring build 2 back. Only that test build contains the failure; it is compiled out of every other build. After each release, the live-update jobs of release.yml install the previous release from its release assets on Windows (NSIS) and Linux (AppImage), without any policy; it must find the new release in the live feed, download it and install it when it quits. Its deb and rpm packages must upgrade to the new release through the live repositories. macOS is not part of this proof yet (see testing).

  • Package repositories (tools/linux-repo/repo.ts): signs the rpm packages and builds the APT repository (one shared pool, stable and beta suites, signed InRelease) and the DNF repositories (metadata pointing at the release downloads, signed repomd.xml). Each channel lists its last two releases; beta also lists stable ones.

  • Package scripts (app/build/linux/): after installation, the deb or rpm package registers the repository of its channel once, with the key it ships; ASTELA_NO_REPO=1 skips this and ASTELA_REPO_URL points at a mirror. Removing the package removes the repository again. Packages ship the repository key from app/build/keys/linux-repo.pub.asc.

  • Repository test: the linux-repos CI job installs 0.0.1-beta.1 from its file on Ubuntu 22.04 and 24.04, Debian 12 and 13 and the two latest Fedora releases, upgrades to 0.0.1-beta.2 and to 0.0.1 through the repositories, and removes the package. The Ubuntu containers use the package mirror of the runner, and the job tries again when a mirror that is being updated cannot serve its lists or packages yet.

Not implemented yet: the rollback on macOS, restoring data snapshots (a rollback does not bring back the profile.db.v<version>.bak copy that a migration of the profile database writes), the update indicator, the “Restart to update” entry and the idle installation, the status on the About and diagnostics pages, the “What’s new” note, the mirror tool, the macOS updater test, and code signing for Windows and with an Apple Developer ID.