0016. Release pipeline without bot commits
Status
Section titled “Status”Accepted
2026-10-25
Validation
Section titled “Validation”How the pipeline is built (.github/workflows/release.yml, .github/workflows/pages.yml, tools/release/):
- Plan:
tools/release/plan.tscompares the version inapp/package.jsonwith the published releases. A release also needs the public keys inapp/build/keys/and a repository name that matchesbrand.json, because every package embeds the keys and the website and update URLs; otherwise the run fails before it builds anything. - Tests and packages:
release.ymlcallsci.ymlon the merged commit, so the release packages are exactly the ones that passed the smoke, updater, end-to-end and performance tests. When thereleaseenvironment holds a macOS certificate, a separate job rebuilds the macOS packages with it, trusts the certificate for code signing as the update spike did, checks the signatures and starts the signed arm64 package; its packages replace the unsigned ones. - Signing and assets: the rpm packages are signed with the repository key before anything is hashed.
update-manifest.jsondescribes the update packages of all builds;SHA256SUMScovers every file and is signed with the update key (SHA256SUMS.sig) and the repository key (SHA256SUMS.asc). The SBOM (sbom.cdx.json, CycloneDX) is generated by Syft from the repository’s lockfile and manifests, and every file gets a build provenance attestation. MSI packages are left out of prereleases. - Publishing: the release notes are the version’s section of
CHANGELOG.md. The draft is created with the owner token, or completed when an interrupted run left one, and published once every asset is uploaded. - Pages:
pages.ymlcollects all published releases (tools/release/collect.ts), signs their manifests and a fresh index whose sequence is the current Unix time, so every deployment has a higher sequence without stored state, builds the package repositories from the deb and rpm files of the listed releases, and builds the website with the download page. The link check runs lychee offline over the built site, with the same path layout as on Pages. After the deployment,tools/release/verify-live.tswaits until the new index is served, checks every signature and download, andapp/tests/linux-repos/live.shinstalls both channels from the live repositories on Ubuntu and Fedora. - Live update proof: after the
pagesjob of a release,live-updatedownloads the previous release of the channel, which the plan names, checks it against itsSHA256SUMSand installs it on Windows (NSIS) and Linux (AppImage, deb and rpm). Without any policy, it must update itself to the new release through the live feed, and the deb and rpm packages must upgrade through the live repositories. The first release has no previous one and skips it. Weekly Pages runs and deployments without a release do not repeat it, although they rebuild the feed and the repositories with the tools of their commit; until the next release, onlyverify-livechecks that output (testing). - Screenshots: a job starts the Linux test build under Xvfb at twice the scale, opens the architecture overview of the freshly built website and captures the window in light and dark mode (
app/tests/screenshots/capture.ts). The color scheme is set through the DevTools protocol, because Xvfb has no system setting for it. The images are release assets; Pages serves them under/media/screenshots/<version>/and/media/screenshots/latest/, and the landing page shows the latest. - Changelog:
pnpm release:prepareuses git-cliff withcliff.toml, which groups features, fixes, performance, packaging and documentation changes.
Not implemented yet: the screenshot gallery of every tab bar position (the tab bar has one position so far), the golden profiles (no data migrations exist yet), freshness.yml and the Mermaid render check.
Context
Section titled “Context”Every change reaches users through main (WORKFLOW.md). A release produces many outputs: packages for every format and architecture, a GitHub release with signatures, SBOM and attestations, the website and documentation, the update feeds and the Linux package repositories. The history of main should contain only commits written by maintainers, with no bot accounts, no app badges and no generated commits for changelogs, websites or feeds.
Two security constraints shape the pipeline. Workflows that run branch code can read repository secrets, so signing keys and the owner token must be out of reach for branches and pull requests. And a release that users or mirrors have downloaded must never change afterwards.
Tools such as release-please, changesets and semantic-release solve versioning by opening bot pull requests or by pushing bot commits and tags.
Decision
Section titled “Decision”Automation (pull requests and merges for owner branches, releases and deployments) runs under the repository owner’s account through GitHub features that create no bot commits.
- Pull requests in two stages (in place today):
branch-push.ymlruns on pushes to the prefixesfeat/,fix/,perf/,refactor/,docs/,test/,build/,ci/andchore/and runspnpm verifywithout any secrets. When it succeeds,auto-pr.ymlstarts throughworkflow_run. It always runs in its version onmain, never checks out branch code, checks the actor and the branch prefix, and usesOWNER_TOKENfrom theautomationenvironment, which onlymainmay use. The token is a fine-grained personal access token of the repository owner, so pull requests and merges show the owner, not a bot or an app. - Merging:
merge.ymlstarts throughworkflow_runafter everyCIandPR titlerun and, likeauto-pr.yml, runs in its version onmainwithOWNER_TOKEN. It squash-merges an open, non-draft pull request of the owner from this repository once the latestci-gateandpr-titleruns on its head have passed. The REST merge names the checked head commit, so commits pushed later never merge unchecked. The squash commit takes the pull request title and an empty body from the repository settings. GitHub’s own auto-merge is not used: enabling it with a fine-grained token was refused with a verified-email error, although the account has verified addresses. If GitHub never started a CI job on a runner, or a job’s runner stopped responding until GitHub cancelled the job at its time limit without any other error,merge.ymlre-runs the failed jobs once with the workflow token and the second result counts. A test that hangs does not qualify: the step’s own limits end it as a failure. - Merge rules: the
mainruleset requires a pull request, the checksci-gateandpr-title, resolved conversations, linear history and squash merges, and forbids force pushes and deletion. Titles and commits follow Conventional Commits without trailers, with one author per pull request. Other maintainers open their own pull requests; their workflows never see release secrets. - Release preparation is local:
pnpm release:prepare <patch|minor|major|beta>sets the version, generates theCHANGELOG.mdsection from the Conventional Commits with git-cliff (which also proposes the next version), updates version references in the documentation and creates the golden profiles for migration tests. A maintainer curates the result and merges it aschore(release): vX.Y.Z. release.ymlruns on every push tomainand onworkflow_dispatch. When no published release exists for the version onmain, it creates a draft release without a tag, builds all packages on native runners, runs smoke and updater tests, signs the artifacts, createsSHA256SUMS, SBOM and attestations, and renders the screenshots. Signing secrets come from thereleaseenvironment, which onlymainmay use. Without a version bump the workflow only deploys the website. Runs are idempotent; a run interrupted before publishing resumes on the existing draft throughworkflow_dispatch. After publishing, a new run would only deploy the website, so a failed deployment or live update proof is resumed by re-running the failed jobs of the same run, which keeps its plan.- Tags: publishing the complete draft with the owner token makes GitHub create the lightweight tag
vX.Y.Z, without a tagger. Nobody pushes tags by hand. Therelease-tagsruleset blocks creating, moving and deletingv*tags; only the admin role, which the release job uses, bypasses it. - Immutable releases are enabled: once published, the assets and the tag of a release cannot change. A faulty release is followed by the next patch version.
- Pages deployment:
release.ymlcallspages.yml, which also runs weekly and on demand. It builds the website from the repository and release data, re-signs the update feeds, signs the APT and DNF metadata, gates on a link check with lychee and deploys with GitHub’s Pages deployment action. There is nogh-pagesbranch. verify-livechecks the live result after every release: feed signatures, download links, andaptanddnfagainst the live repositories.- No bot pull requests for dependencies: Dependabot alerts stay on, automatic security update pull requests are off.
freshness.ymlturns red when an Electron security release or a known vulnerability is pending, and a maintainer updates by hand.
All actions are pinned by commit SHA, every job has minimal permissions, and signing steps run only when their secrets exist, so unsigned builds pass through the same pipeline. Release secrets are described in security.
Consequences
Section titled “Consequences”Positive:
maincontains only squash commits authored by maintainers; generated outputs live in releases and deployments.- Signing keys and the owner token stay unreachable from branch and pull request code, even when a branch changes workflow files.
- One merge is expected to produce the complete release without manual steps, and resuming or re-running a release is safe.
- Published versions and tags cannot change, which users, package managers and mirrors can rely on.
- The changelog is curated by people instead of being copied from commit subjects.
Negative:
- The owner token is a single point of failure: when it expires, automatic pull requests and publishing stop.
freshness.ymlis meant to warn before that happens. - The owner cannot approve their own pull requests, so review of owner changes is a team rule that GitHub does not enforce.
- With immutable releases every mistake costs a patch version, so drafts must be verified completely before publishing.
- Preparing a release needs a maintainer to run a local script; versioning is deliberately not fully automatic.
- The pipeline is custom workflow code that has to be maintained and hardened, with actionlint and zizmor in CI.
Alternatives considered
Section titled “Alternatives considered”- release-please: maintains a bot release pull request and pushes bot commits.
- changesets: needs a changeset file in every pull request and a bot pull request that bumps versions.
- semantic-release: pushes version commits and tags from CI and derives versions without human curation.
- A
gh-pagesbranch for website, feeds and repositories: every deployment would be a generated commit. - A GitHub App for automation: pull requests and merges would carry an app identity and badge.
- Tags pushed by hand or by CI before the build: a tag could point at a state that never became a complete release, and it could be moved.
- Release secrets as repository secrets: branch workflows could read them.