Skip to content

0016. Release pipeline without bot commits

Accepted

2026-10-25

How the pipeline is built (.github/workflows/release.yml, .github/workflows/pages.yml, tools/release/):

  • Plan: tools/release/plan.ts compares the version in app/package.json with the published releases. A release also needs the public keys in app/build/keys/ and a repository name that matches brand.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.yml calls ci.yml on the merged commit, so the release packages are exactly the ones that passed the smoke, updater, end-to-end and performance tests. When the release environment 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.json describes the update packages of all builds; SHA256SUMS covers 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.yml collects 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.ts waits until the new index is served, checks every signature and download, and app/tests/linux-repos/live.sh installs both channels from the live repositories on Ubuntu and Fedora.
  • Live update proof: after the pages job of a release, live-update downloads the previous release of the channel, which the plan names, checks it against its SHA256SUMS and 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, only verify-live checks 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:prepare uses git-cliff with cliff.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.

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.

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.yml runs on pushes to the prefixes feat/, fix/, perf/, refactor/, docs/, test/, build/, ci/ and chore/ and runs pnpm verify without any secrets. When it succeeds, auto-pr.yml starts through workflow_run. It always runs in its version on main, never checks out branch code, checks the actor and the branch prefix, and uses OWNER_TOKEN from the automation environment, which only main may 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.yml starts through workflow_run after every CI and PR title run and, like auto-pr.yml, runs in its version on main with OWNER_TOKEN. It squash-merges an open, non-draft pull request of the owner from this repository once the latest ci-gate and pr-title runs 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.yml re-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 main ruleset requires a pull request, the checks ci-gate and pr-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 the CHANGELOG.md section 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 as chore(release): vX.Y.Z.
  • release.yml runs on every push to main and on workflow_dispatch. When no published release exists for the version on main, it creates a draft release without a tag, builds all packages on native runners, runs smoke and updater tests, signs the artifacts, creates SHA256SUMS, SBOM and attestations, and renders the screenshots. Signing secrets come from the release environment, which only main may use. Without a version bump the workflow only deploys the website. Runs are idempotent; a run interrupted before publishing resumes on the existing draft through workflow_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. The release-tags ruleset blocks creating, moving and deleting v* 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.yml calls pages.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 no gh-pages branch.
  • verify-live checks the live result after every release: feed signatures, download links, and apt and dnf against the live repositories.
  • No bot pull requests for dependencies: Dependabot alerts stay on, automatic security update pull requests are off. freshness.yml turns 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.

Positive:

  • main contains 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.yml is 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.
  • 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-pages branch 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.