Releasing Hex¶
One run of the Release workflow (.github/workflows/release.yml)
builds, signs, and publishes every platform from one commit to the Anomaly
release service (anomalyco/release, https://anoma.ly/release). Hex's
names on the service are in plans/release-service.md.
The service's dev stage, https://dev.anoma.ly/release, is only for testing
the release service itself: Hex never publishes to it or reads from it. Builds
are tested before stable on the production service's dev channel.
Like OpenCode's release, building is split from everything that holds a
secret. .github/workflows/ (Release, Site deploy, Legacy appcast) and
.github/release/ are the security boundary:
- Build jobs run repository code (cargo, build.rs, bun, makepkg, brew) with no environment, no secret, and no id-token, and make unsigned files.
- Sign and publish jobs run in the
releaseenvironment, check out only.github/release/(sparse checkout), and run only those scripts and SHA-pinned actions, on artifacts they treat as data. They never build, install dependencies, or run repository code or the artifacts. The one installed tool is the cf CLI that deploys the site, pinned with its integrity hashes in.github/release/cf/package-lock.jsonand installed withnpm ci --ignore-scriptsbefore the token is in the step's environment. - Artifacts each carry a
SHA256SUMS; the job that made one reports that manifest's digest as a job output, and every job that downloads it checks the digest and every file (.github/release/verify-artifacts.sh), so nothing swapped in between jobs is signed or published.
Release (push to dev: dev channel; workflow_dispatch: ref + channel check | dev | stable (from production))
├─ plan .github/release/plan.sh: version, channel, tag, notes
├─ build build-desktop.yml: Linux x64 + ARM64 (Ubuntu 24.04) executables, .deb, [build]
│ .rpm (installed and run on Ubuntu and Fedora); Windows x64 + ARM64
├─ build-macos Hex.app as anomaly.hex, ad-hoc signed (HEX_RELEASE_UNSIGNED) [build]
├─ sign-macos Developer ID inside-out, notarize + staple, ZIP + DMG (sign-macos.sh) [release]
├─ verify-macos validate-app.sh, Gatekeeper, updater requirement, launch [build]
├─ sign-windows Azure Artifact Signing of both hex.exe, Authenticode check [release]
├─ installers windows-installers.yml: Setup.exe around them, tested on x64 + ARM64 [build]
├─ sign-windows-installers Azure Artifact Signing of both Setup.exe [release]
├─ check-linux, -macos, stable, check: the full suites on the candidate (check-<os>.yml, called [build]
│ -windows here; pull requests only compile, dev pushes run no checks)
├─ onboarding stable, check: scripts/check-onboarding.sh (scene goldens + a first run [build]
│ in a clean data folder, Linux). Stable publishes only once these four pass
├─ publish OIDC: every signed target, then the release (release-service.sh); Nix [release]
├─ github last: tag v<version> on the released commit (tag-release.sh), then the [contents: write]
│ GitHub release mirror (publish-github-release.sh); GITHUB_TOKEN only.
│ check: pushes and deletes a throwaway v0.0.0-test-<run> tag
├─ aur-stage → aur stable: makepkg + install from the published files → push-aur.sh [build → release]
├─ homebrew-stage → homebrew stable: render + brew style/audit → push-homebrew.sh [build → release]
├─ record-aur, record-brew OIDC: the package manager serves the version [release]
└─ dev-notes-stage → dev-notes dev: PR titles since the last dev build (release-dev-notes.sh) → [build → release]
dev-notes.json in hex-production-files (publish-dev-notes.sh)
Without a platform's signing values, dev and check runs go on without that
platform (the summary says so); stable fails. The site is no longer deployed
by Release: after a stable release, run Site with mode: production from
production.
Signing values (release environment)¶
- macOS: secret
APPLE_SIGNING_CERTIFICATE(base64.p12with the Developer ID Application identity for team5NZ4Q7NXJ4; optional secretAPPLE_SIGNING_CERTIFICATE_PASSWORDif the.p12has one), secretAPPLE_NOTARY_KEY(the App Store Connect API.p8), and variablesAPPLE_NOTARY_KEY_IDandAPPLE_NOTARY_ISSUER_ID.macos-credentials.shimports them into a throwaway keychain and prints nothing secret. - Windows:
AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID,AZURE_TRUSTED_SIGNING_ENDPOINT,AZURE_TRUSTED_SIGNING_ACCOUNT_NAME,AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE, with a federated credential forrepo:anomalyco/hex:environment:release(docs/windows-signing.md).
Release tags¶
v* tags are not restricted: tags trigger nothing. The final github job
pushes v<version> with the built-in GITHUB_TOKEN (contents: write on
that job only, no environment).
Who can change the boundary¶
.github/CODEOWNERS names @thdxr for /.github/. GitHub has no push rulesets
(file-path restrictions) for public repositories, so the lock is the branch
ruleset "release boundary (.github needs Dax)" on dev and production:
pull requests only, with code-owner review, and Dax as the one bypass actor.
Repository admins can still change rulesets.
The release service¶
The service authorizes the publish jobs by their GitHub OIDC token alone:
registry/hex.json in anomalyco/release admits repository anomalyco/hex
(by name and id) and workflow .github/workflows/release.yml running
from the same ref it was triggered on, for artifact desktop on both the dev
and production services, as two publishers:
Like OpenCode, every run uses one environment, release, with no required
reviewer (every push to dev publishes); its deployment policy admits dev
and production. The registry ties channels to refs, so a
build of dev can never publish to stable. No key or secret publishes:
only the publish, record-aur, and record-brew jobs present an OIDC
token, and the Windows signing jobs one for Azure.
publish uploads each file under its own name with its SHA-256 (files over
95 MiB in 90 MiB parts), then posts the release naming each target by file
and hash, with distributions direct, aur (hex-bin), deb-x64,
deb-arm64, rpm-x64, rpm-arm64, brew (anomalyco/tap/hex), and nix.
It then downloads every published URL and compares it with the local bytes.
Package-manager entries gain a version only once that package manager serves
it: nix and the deb/rpm kinds at publish (the flake builds the release
commit; the packages are files of the release), aur after the push, and
brew after the cask push. Manifests are not signed;
clients check HTTPS, each file's size and SHA-256, and the OS code signature
(Developer ID on macOS, Authenticode on Windows). Linux has only the hash.
Homebrew¶
packaging/homebrew/hex.rb is the cask anomalyco/tap/hex: the anomaly.hex
app from the release's macos-arm64-dmg file URL and SHA-256, auto_updates
true (Hex updates any writable Hex.app itself), a livecheck on the stable
manifest, and zap paths for the new identity and the shared hex data folder.
The homebrew-stage job renders it on a macOS runner and runs brew style
and brew audit --cask --strict --online in a local copy of the tap, without
secrets; the homebrew job pushes that verified file with the tap deploy key
(.github/release/push-homebrew.sh). Stable releases only.
Channels¶
check(the dispatch default, run fromdev) builds, signs, and verifies every platform as0.0.0-dev-<run number>inreleaseand publishes nothing anywhere: a dry run of the build and packaging.devpublishes a build ofdevto the production service'sdevchannel (https://anoma.ly/release/hex/desktop/dev.json), which installs set to the Dev update channel follow andhttps://anoma.ly/hex/download/devlinks. Every push todevruns it, except pushes that change onlydocs/,site/, or Markdown; a running dev release always finishes, and only the newest push that lands meanwhile waits to run next (GitHub drops the ones between), andgh workflow run release.yml --ref dev -f channel=dev -f ref=devruns it by hand. It runs inrelease, which needs no approval. Each dev build's notes are the titles of the PRs merged since the previous dev build, without CI fixes and changes outside the app (scripts/release-dev-notes.sh); once the build is published they joinhttps://anoma.ly/hex/files/dev-notes.json, which the changelog athttps://anoma.ly/hex/dev(the dev release'snotes_url) and the app's Restart to update read. The version is0.0.0-dev-<run number>, as OpenCode numbers its dev builds. Versions are display text that nothing compares: each channel has one current release and installs follow it, so a dev build below 2.3.1 is fine, and a rollback is making an earlier release current (the service'spromotewith a historysequence, or Make current in its admin panel). Every build is stamped with its channel (HEX_BUILD_CHANNEL); an install whose Update channel setting names another channel offers that channel's current release ("Switch to Stable 2.3.1") and switches only once the user confirms. Source, local (HEX_LOCAL_BUILD=1) and test builds never check or offer a switch. The first build after this change, 2.3.2-dev.on 2026-10-27, carried the new client to installs whose older client took only higher versions; every build since is 0.0.0-dev-<run>. It publishes only signed builds, because the updater checks signatures: untilreleasehas the Apple secrets it has no macOS targets, and until Azure Artifact Signing works there it has no Windows targets (the run summary says so); Linux needs no signing. It publishes only the release: no GitHub release, AUR, Homebrew, Nix record, or site deploy. Publishing a dev build is not a release: it leavesproduction, the stable channel, and the release freeze alone. Installs on the Dev channel check every 10 minutes and when the window opens or gains focus (at most once a minute), so a push reaches them within minutes of its run finishing.stable, dispatched fromproduction(gh workflow run release.yml --ref production -f channel=stable -f ref=production), publishes the crate version fromCargo.tomltohttps://anoma.ly/release, the AUR, and Homebrew. It needs every platform signed anddocs/releases/<version>.md, and refuses a version already taggedv<version>orapp-v<version>. Tags trigger nothing. Only last, once every platform and the release service published, thegithubjob tagsv<version>on the released commit withGITHUB_TOKENand the GitHub releasev<version>(GitHub's "Latest"; SDK releases never take it) mirrors the files. A failed run leaves no tag. Releases through 2.3.1 usedapp-v<version>tags, which stay as history. Deploy the production site afterwards with Site.
To ramp, halt, roll back, or force an update, use the service's actions
(rollout, halt, promote with a history sequence, required;
anomalyco/release docs/publishing.md) from the admin panel or a job with
the same OIDC identity.
Cutting a release¶
Work lands on dev. production holds what was released and moves only by a
fast-forward from dev; nobody commits to it directly, and its ruleset
refuses force pushes and deletion.
- Bump
versioninCargo.toml(andCargo.lock), adddocs/releases/<version>.md, push todev. Runscripts/check-onboarding.shon that commit and give Dax its result and scene images with the go/no-go; stable releases need his explicit go-ahead. - Let the push to
devpublish its dev-channel build (https://anoma.ly/release/hex/desktop/dev.json) and try it on an install set to the Dev update channel. - Fast-forward
productionto that commit (git push origin <commit>:production, which refuses anything but a fast-forward; the "release boundary" ruleset lets only Dax push it directly, others open a pull request), then run Release fromproductionwithchannel: stable(its defaultref). Stable runs refuse any other branch. The run tagsv<version>itself at the end; never push a release tag by hand. - Bump the README version link. The site reads the release from the service when it deploys.
- First release on the service only: set the informational item's version in
packaging/macos/legacy-appcast.xml(a 2.4.0 / build 20400 placeholder) to the release, push it, deploy the old hosts' redirect, and publish the item for 2.3.1 Macs (both below, "Transition from Kit's hosting").
The first Anomaly-signed macOS release also changes the bundle ID to
anomaly.hex (docs/plans/macos-signing.md). Its notes must tell Mac users
that macOS sees a new app: grant Microphone, Accessibility, and (for
meetings) Screen & System Audio Recording once more, removing any
stale Hex entry in System Settings first; turn Launch at login on again; and
point DisableUpdates profiles at anomaly.hex. Settings, history, and
models do not carry over from 2.3.1 on any OS: 2.3.1 kept them in
voice-control, and Hex now reads only hex and imports nothing. Decide how
2.3.1 installs reach it first (same doc, "Existing installs").
Versions are display text and never compared: each channel has one current release, which installs follow.
Hosting¶
Hex lives under one path on anoma.ly (no subdomains) until a vanity domain
exists. site/cloudflare.config.ts defines both environments; --mode
picks one:
cloudflare.config.ts (cf deploy --prebuilt --mode <mode>, from CI only)
├─ production: Worker hex-production-site routes anoma.ly/hex, anoma.ly/hex/* bucket hex-production-files
└─ dev: Worker hex-dev-site routes dev.anoma.ly/hex, dev.anoma.ly/hex/* bucket hex-dev-files
https://anoma.ly/hex/ the site; downloads read the service's stable manifest at build time
├─ download/dev 302 to the dev channel's macos-arm64-dmg, read per request
├─ dev the dev builds' changelog, rendered per request from files/dev-notes.json
├─ install-linux.sh, install-windows.ps1 the installers, copied from .github/release/ at build time
└─ files/ the frozen 2.3.1 bucket for old clients (below)
The site's download links come from site/scripts/release-data.ts, which
reads the stable manifest of the mode's service when the site builds; until
the service has a stable Hex release they name the frozen 2.3.1 downloads.
The installers fetch the stable manifest, download their target, check its
size and SHA-256, and write the direct-install receipt.
/hex/download/dev is the permanent team link to the newest Mac dev build:
the Worker reads its host's service dev.json on every request and redirects
to the macos-arm64-dmg file, or answers 404 with a plain message when no dev
release (or no Mac build of it) exists. The release service is another Worker
on the anoma.ly zone, so the site Worker sets the global_fetch_strictly_public
compatibility flag for that fetch to reach it. A DMG install updates itself
from the dev channel because it was built for that channel; installs from
the stable link switch in Settings › General › Update channel, after
confirming the switch.
The route ships with the Worker, so it is live on production only after a
production site deploy of a commit that has it: the next stable Release, or
Site dispatched with mode: production from production once production
contains it. Both move or deploy production, which the release freeze
forbids, so until the freeze lifts the link works only on dev
(https://dev.anoma.ly/hex/download/dev, which reads the dev service).
Deploys run only from CI (.github/workflows/site.yml and site-deploy.yml):
a push to dev that touches site/ deploys dev, and Site can be dispatched
with mode: production from production only (after a stable release).
Site deploy is split like Release:
Site deploy (mode dev | production)
├─ build bun install + site/scripts/build.ts --mode <mode>, no environment or secret; [build]
│ uploads site/.cloudflare/output (Build Output) with its SHA256SUMS digest
├─ deploy sparse checkout of .github/release/; npm ci the pinned cf CLI; verify the [release]
│ artifact's digest; deploy-site.sh <mode>
└─ verify the live site answers 200 and bun run check:downloads, no secret [build]
The site's Worker code and assets come from repository code anyone with push
access can merge, so .github/release/deploy-site.sh treats the artifact as
untrusted: it deploys only the Build Output files, and only if the root config
is a non-preview build of that mode and the Worker config is exactly that
mode's Worker (hex-dev-site or hex-production-site), its two anoma.ly
routes, workersDev/previewUrls off, and the bindings ASSETS, FILES
(that mode's bucket), and CANONICAL_HOST. Any other Worker name, route,
binding, key, Worker, or Container is refused: a binding would give the
Worker access to another resource, and the token can write every Worker. A
change to those parts of site/cloudflare.config.ts therefore needs the
matching change in deploy-site.sh (Dax).
Set once by hand on the anomaly account, because the config only binds them:
the two buckets (cf deploy provisions only unnamed bindings; a missing named
bucket fails the deploy), the expire-staging lifecycle rule on
hex-dev-files, and an anoma.ly http_config_settings rule that turns off
Browser Integrity Check for /hex/files/ on both hosts (it returned 403 to
Python's default user agent).
Two account tokens, each used by one locked step only:
hex-site-deploy(releasesecretCLOUDFLARE_API_TOKEN): Workers Scripts Write (account) and Workers Routes Write (zoneanoma.ly), used only bydeploy-site.sh. It has no R2 access: deploying a binding needs none. Cloudflare supports per-Worker roles (Individual Workers Editor) that would limit it to the two site Workers, but they can be granted only from the dashboard or by an API token, not by thecfOAuth login; until it is recreated that way,deploy-site.sh's Worker-name check is the limit. Routes are always written on deploy, andcfthen needs Routes Write on the zone. Recreate rather than edit a token's policies: an edited policy did not take effect on the routes API in testing.hex-r2-production-files(releasesecretR2_SECRET_ACCESS_KEY, variableR2_ACCESS_KEY_ID= its id): object write onhex-production-filesonly, used through.github/release/r2-put.shby the Installers and Legacy appcast workflows and Release's dev-notes job.
Old clients (2.3.1 and earlier)¶
https://anoma.ly/hex/files/ (hex-production-files) keeps serving the 2.3.1
linux-update.json, windows-update.json, windows-aarch64-update.json,
appcast.xml, and their files, frozen: nothing updates them anymore (the
bucket now also holds the installers and dev-notes.json), and those
installs see no update on their own. The new Hex is effectively a new
app (new updater, and on macOS a new bundle ID), so:
- macOS: at the first release on the service, add one informational item
to
appcast.xmlthat tells 2.3.1 users to download the new Hex from https://anoma.ly/hex (no enclosure, so Sparkle installs nothing). The item is prepared with the macOS client work; upload it with the Legacy appcast workflow (.github/workflows/legacy-appcast.yml, dispatch fromproduction), which replaces onlyappcast.xml. Superseded (Kit, 2026-11-02): the 2.3.1 and Swift feeds offer the released ZIP itself, signed with their old Sparkle key, so Install Update moves those Macs onto the new Hex (docs/plans/swift-app-handoff.md, "Legacy Update"). KeepSUPublicEDKeyinapp/Info.plist. - Windows signer: the updater accepts only a Valid Authenticode signature
whose signer is
AUTHENTICODE_SIGNERinsrc/updater/windows.rs(OpenCode's Artifact Signing subject). Check the first Azure-signed Hex build's subject against it before a stable release; unsigned builds never update. - Linux and Windows: the feeds stay at 2.3.1; the site and README tell
users to reinstall.
HEX_RELEASE_SIGNING_KEY(the ed25519 key 2.3.1's feeds were signed with) now signs every Linux executable Release publishes (.github/release/sign-linux.sh).
Transition from Kit's hosting¶
Releases up to 2.3.1 compiled https://downloads.hex.kitlangton.dev (Kit's
personal hex-releases bucket; the website and installers used the .com
alias); later 2.3.x builds compiled https://anoma.ly/hex/files. Builds on
the release service compile https://anoma.ly/release.
hex-production-fileswas seeded with byte copies of every object the old bucket served (the 2.3.1 feeds and artifacts, installers, the appcast, DMGs, Sparkle ZIPs, and Setup.exe installers).- Both hosts are custom domains of
hex-releasesin Kit's personal Cloudflare account and serve the bucket directly until the redirect is deployed. The redirect is the Worker inpackaging/legacy-downloads/(hex-legacy-downloads, routesdownloads.hex.kitlangton.dev/*anddownloads.hex.kitlangton.com/*→https://anoma.ly/hex/files/<path>, 302, path and query preserved). Deploy it with Kit's personal Wrangler login, before the Legacy appcast workflow runs, which checks both hosts:
It changes no bytes old installs get (hex-production-files holds copies
of everything the bucket served), so it may go up before the notice.
Sparkle and the Linux/Windows updaters (curl --location, HTTPS-only
redirects, since the first updater) follow it, so those installs read the
frozen 2.3.1 feeds and, once published, the informational appcast item. The
redirect must stay up while old installs remain; wrangler delete with the
same config takes it down.
- Kit's bucket stays as it is, frozen at 2.3.1, with its
pub-089d681d41754031a4aefa7017d8c2fb.r2.dev endpoint: builds 2.0.9–2.1.16
read that URL directly (no redirect is possible there), update to 2.3.1,
and reach the new origin from there.
- Without the redirect, 2.3.1 Macs read the bucket's own appcast.xml and
never see the informational item.
Secrets (release environment, limited to dev and production)¶
dev is allowed because dev-channel builds and dev site deploys run from
it. No reviewer is required.
| Name | Use |
|---|---|
| (none) | publishing to the release service: the job's GitHub OIDC token |
CLOUDFLARE_API_TOKEN, var CLOUDFLARE_ACCOUNT_ID |
site deploys (hex-site-deploy, used only by deploy-site.sh) |
R2_SECRET_ACCESS_KEY, var R2_ACCESS_KEY_ID |
uploads to hex-production-files (hex-r2-production-files): the installers, the legacy appcast, and dev-notes.json |
AUR_SSH_PRIVATE_KEY |
pushes hex-bin (key github-actions-hex-aur) |
HOMEBREW_TAP_DEPLOY_KEY |
pushes Casks/hex.rb (deploy key github-actions-hex-homebrew, write access on anomalyco/homebrew-tap only) |
AZURE_* |
Authenticode (docs/windows-signing.md) |
APPLE_* |
macOS signing and notarization (docs/plans/macos-signing.md) |
HEX_RELEASE_SIGNING_KEY |
ed25519 PEM; signs each Linux executable (<target>-sig), public key .github/release/linux-signing.pub and src/updater/linux.rs |
SPARKLE_ED_PRIVATE_KEY |
old 2.3.1 appcast key; unused, kept for old clients |
Linux packages¶
- Direct install (
curl -fsSL https://anoma.ly/hex/files/install-linux.sh | sh, kept current by the Installers workflow; stable, or dev until there is a stable release): the user-local version layout plus theinstall.jsonreceipt; it updates itself from the service's manifest and the window offers "Update" (restart into the staged version). - AUR
hex-bin(packaging/aur, staged byscripts/release-aur.sh, pushed by.github/release/push-aur.sh):/usr/bin/hex, the desktop entry, and the systemd user unit/usr/lib/systemd/user/hex.service, from the release service'slinux-x64(x86_64) orlinux-arm64(aarch64) file. Not a prebuilt of the officialextra/hex(a hex dump tool that installs/usr/bin/hx), so it neither provides nor conflicts with it; its description says so. Maintained under Dax's AUR account. - .deb and .rpm
hex(packaging/linux/nfpm.yaml, built byscripts/package-linux.shwith nfpm in the build jobs): the same three files as the AUR package, for x64 and arm64, published aslinux-<arch>-debandlinux-<arch>-rpm(hex.deb,hex.rpm). There is no apt or dnf repository: the release service'sdeb-<arch>/rpm-<arch>distributions carry the command that downloads the stable package from its alias link and installs it withaptordnf. Dependencies name Ubuntu 24.04 packages (either name across its 64-bit time_t rename) and, for rpm, shared libraries, so Fedora and openSUSE both resolve them. Package versions turn the first hyphen into a tilde (0.0.0~dev.42,2.5.0~beta.1). - Nix:
nix/package.nixbuilds from source; flake inputs update it. - Source (
scripts/install-linux.sh): the version layout with asource-buildmarker; the checkout updates it.
An executable outside the user-local layout that the user does not own and
pacman, dpkg, or rpm owns (/usr/bin/hex), or that lives in the Nix store,
never self-updates; Settings says
"Updated by your package manager". Source installs say "Updated from your
source checkout".
Both Linux architectures build natively on Ubuntu 24.04 runners
(ubuntu-24.04, ubuntu-24.04-arm) inside a Debian 12 image
(scripts/build-linux-release.sh, packaging/linux/build.Dockerfile, with
Vulkan headers and glslc from Vulkan SDK 1.4.341), so every Linux file needs
only glibc 2.36 and GCC 12's libstdc++ (Debian 12, Ubuntu 23.04, Fedora 37,
current Arch). .github/release/check-glibc-floor.sh fails the build if the
executable needs a newer symbol, and the build installs the .deb and runs
the executables on Debian 12. ARM64 keeps the Vulkan backend like x64; ggml's CPU
kernels stay portable (GGML_NATIVE=OFF, baseline ARMv8-A). There is no
AppImage: the bare executable already runs without installing, and Hex's
systemd user unit and updater want a stable executable path that an AppImage
does not have.
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 hex19/hex