Review follow-up: the dispatch clause matched any non-main ref, so a non-release tag dispatch also cancelled its predecessor. It now requires refs/heads/ (tag case added to the test; red on the previous head). _gha_expr models startsWith/endsWith (case-insensitive, per the Actions docs). .github/workflows/README.md concurrency section now describes side-branch dispatches, and notes GitHub may still replace a pending run in a non-cancelling group.
GitHub Actions workflows
This directory has 57 workflow files. Most of them are reusable workflows
(on: workflow_call). They never start on their own; an entry-point workflow
calls them. This README covers what starts each workflow, which jobs it skips
and why, how to run it by hand, and how a regular CI run differs from a
release run.
The YAML is the source of truth. If this file and a workflow disagree, the workflow wins. Fix this file in the same PR.
- Map
- Things to know first
- Regular CI:
ci.yaml - Standalone PR / main checks
- PR helpers
- Post-merge automation on
main - Scheduled monitors
- Releases
- Manual-only and iteration-branch workflows
- Full index
- Known dormant or inert paths
Map
pull_request / push main
├─ ci.yaml (CI) ── detect ──┬─ tests.yml ─────────────── (e2e jobs: release/dispatch only)
│ ├─ tests-os.yml ──────────── windows-install-update-e2e.yml
│ ├─ lint.yml, js-tests.yml, rust-tests.yml, catalog-tests.yml
│ ├─ remote-backends.yml, bootstrap-installer.yml, docs-site-checks.yml
│ ├─ e2e-desktop-core.yml, e2e-desktop-update.yml (release/dispatch only)
│ ├─ e2e-desktop.yml (disabled: `if: false`)
│ ├─ repo-guards.yml (one job: every cheap guard as a lane-gated step)
│ └─ all-checks-pass ← the ONLY required branch-protection check
│ (+ the CI timing report steps)
├─ docker.yml (own run; build/test, push :main on main)
├─ nix.yml (own run; nix flake check)
└─ path-filtered: pm-bundle, termux-verify, plugin-catalog-ci, sandbox-image, windows-bundle-sdk
workflow_run CI started → ci-review-comment.yml (live PR comment)
PR labeled run-e2e → label-rerun.yml (reruns CI, Docker, Nix)
schedule daily 06:41 UTC → canary-release.yml → (release.py dispatches) desktop-bundled-release.yml
└─ desktop-bundle-smoke.yml
release.py release → stable-release.yml ─┬─ ci.yaml (release: true, strict gate)
(pushes rc.N-vX.Y.Z, ├─ docker.yml (release-phase test → publish)
dispatches on main) ├─ nix.yml, pm-bundle.yml, termux-verify.yml
├─ windows-venv-e2e.yml, install-e2e.yml
├─ desktop-bundled-release.yml (candidate / publish)
└─ install-e2e-{windows,macos}-run.yml (signed upgrade)
Stable Release completed (failed) → stable-release-publication.yml (rerun failed jobs / publish)
Things to know first
Entry points vs reusable workflows
| Kind | What starts it | Files |
|---|---|---|
| Entry point | pull_request, push, schedule, workflow_run, workflow_dispatch, release |
ci.yaml, docker.yml, nix.yml, canary-release.yml, stable-release.yml, and the others under the headings below |
| Reusable only | Another workflow's uses: ./.github/workflows/<file> |
Most ci.yaml children (tests.yml, lint.yml, …), desktop-bundle-smoke.yml (also dispatchable), install-e2e-run.yml, install-e2e-{windows,macos}-run.yml |
| Both | Its own triggers and a caller | nix.yml, docker.yml, pm-bundle.yml, termux-verify.yml, windows-venv-e2e.yml, windows-install-update-e2e.yml, install-e2e.yml, desktop-bundled-release.yml, ci.yaml, archive-inputs.yml |
A reusable workflow does not show up as its own run in the Actions tab. Its
jobs appear inside the caller's run, under the caller's job name (for example
Python tests / Run tests (1/2)).
The commit input
Almost every reusable workflow takes commit ("Commit to test and build.
Empty keeps the triggering commit."). Regular CI leaves it empty, so it tests
github.sha. A stable release dispatches on main, so github.sha is
main's tip there, not the commit under test. stable-release.yml therefore
passes the admitted claim commit down through commit (or ref for
pm-bundle.yml). tests/ci/test_stable_release_commit_threading.py enforces
this.
Concurrency (what cancels what)
The pattern repeats across files:
- Pull requests: one run per PR per workflow. A new push cancels the older
run (
cancel-in-progress: trueforpull_request). - Dispatches on a side branch (
refs/heads/*other thanmain): keyed ongithub.ref; inci.yamla newer dispatch on the same branch cancels the older run, so a re-dispatch never queues behind a stale one. - Pushes to
main, dispatches onmainor a tag: keyed ongithub.ref.ci.yamldoes not setcancel-in-progressfor them (GitHub may still replace a pending run in the group). Some children (tests.yml,lint.yml, …) still usecancel-in-progressfor non-tag refs. - Release /
commit-pinned calls: keyed ongithub.run_id, never cancelled. A parent rerun cannot kill a child that is still running. - Release controllers:
canary-release,stable-release(shared withstable-release-publication),pages(deploy),docs-search-monitor,install-e2e-red-tracker. All of them queue (cancel-in-progress: false). GitHub keeps only one pending run per group, so a newer queued run replaces an older queued one.
"Skipped" is not "failed"
A job whose if: is false reports skipped, and no runner starts. In
regular CI, all-checks-pass treats a skipped job as fine when detect
did not select it. In release mode a skipped required job fails the gate
(see Release mode).
Forks and secrets
ci.yamlruns PR-controlled code. It never getssecrets: inheritor GitHub App credentials. Do not add them.- Jobs that need secrets either run only on
mainpushes or dispatches (docker.ymlpublish,archive-inputs.yml,deploy-site.yml) or check the repo (github.repository == 'NousResearch/hermes-agent'). On a fork they skip. - Jobs that need R2 read the secret into job
envand skip their steps when it is empty (a job-levelif:cannot readsecrets). This applies toarchive-inputs, thecanary-releaseR2 prune, andtermux-verify→r2-provisioned. An unprovisioned fork or environment stays green and does not go red. ci-review-comment.ymlskips PRs from forks (it needs a write token).
Events created by GITHUB_TOKEN do not start workflows
GitHub does not start workflows for push (including tags) or release
events made with the default GITHUB_TOKEN. workflow_dispatch and
workflow_run are the exceptions. This is why:
- Release controllers start their follow-up builds with explicit
gh workflow rundispatches (release.py,skills-index.yml→deploy-site.yml). - Tags and releases that automation creates with
GITHUB_TOKEN(canary tags, finalvX.Y.Zreceipts, GitHub release publication by the sequencer) do not start thepush: tags/release: publishedtriggers below. Those triggers fire only when a human or an App token creates the tag or release. js-autofix.ymlpushes with an App token, so its pushes do start CI. Its concurrency group prevents a loop.
Runners
blacksmith-* labels are Blacksmith runners (4vcpu for small jobs,
32vcpu for test suites and builds, -arm for native arm64). macos-15,
windows-latest-32-core and windows-latest-32-arm-core are GitHub-hosted.
No workflow runs on Intel macOS. Some publish jobs run on ubuntu-latest on purpose:
docker buildx imagetools cannot commit manifest lists to Docker Hub from
Blacksmith.
Regular CI: ci.yaml
Name: CI. Required check: All required checks pass (v2).
Triggers
| Event | Behaviour |
|---|---|
pull_request (any branch, any path) |
The classifier selects lanes from the PR diff. E2E lanes are forced off. |
push to main |
Every lane is on (a push has no PR diff, so the classifier fails open). E2E lanes are forced off. PR-only jobs skip. |
workflow_dispatch |
Every lane is on, E2E included. Inputs: release (strict gate), strict_acceptance. |
workflow_call from stable-release.yml |
release: true and commit: <claim commit>. Every lane is forced on, E2E included, with the strict gate. |
There are no paths: filters: CI starts on every PR. The detect job
decides what runs.
The detect job and lanes
detect runs .github/actions/detect-changes, which feeds the changed files
to scripts/ci/classify_changes.py. Each lane comes out as true or false.
The classifier fails open:
- An empty diff (push, dispatch, or a compare-API failure) turns every lane on.
- Any change under
.github/turns every lane on. pythonis a denylist. It is off only when every changed file is provably prose, frontend-only, Docker meta, Nix-only, or catalog entry data.
Then a gate step in ci.yaml rewrites the result:
- The
e2e*lanes are forced false unless the run is a release or aworkflow_dispatch. A diff or a label cannot turn them on for a PR or a push. - With
release: true, every lane is forced true.
| Lane | Selected on a PR when the diff touches… (summary; see classify_changes.py) |
|---|---|
python |
anything not provably irrelevant to Python (includes skills/, website/docs/, website/scripts/, website/static/oauth/) |
python_prod |
as python, excluding tests/ |
frontend |
ui-tui/, web/, apps/, tests-js/, root pnpm manifests (package.json, pnpm-lock.yaml, pnpm-workspace.yaml), the WhatsApp bridge, JS under scripts/build/ |
rust |
.rs, Cargo files, bootstrap-installer crate paths |
catalog |
plugin-catalog/*.yaml, contributors/emails/ (a diff with only these runs catalog tests instead of the Python suite) |
site |
the Docusaurus site |
bootstrap |
install.sh and its pin fragments and stamp |
desktop_updater |
scripts/desktop-update/ and its Windows hand-off tests |
remote_backends |
the SSH/Docker terminal backends, the terminal tool, their real-backend tests |
uv_lock |
pyproject.toml or uv.lock |
deps |
pyproject.toml |
scan |
Python files, .pth, setup hooks |
npm_lock |
pnpm-lock.yaml or any package-lock.json |
docker_meta |
Dockerfiles and Docker scripts |
docker, nix |
slow lanes, read by docker.yml and nix.yml (not by ci.yaml) |
e2e, e2e_upgrade, e2e_desktop_core, e2e_desktop_update |
computed, then forced off except on release or dispatch |
A cross-language fixture (_SHARED_FIXTURE_CONSUMERS) also turns on the lanes
of every suite that reads it.
Jobs and when each runs
| Job (check name) | Workflow | Runs when | Skipped when |
|---|---|---|---|
| Detect affected areas | inline | always | never |
| Python tests | tests.yml |
python or e2e or e2e_upgrade |
none of those lanes |
↳ Run tests (1/2, 2/2) |
always inside tests.yml |
||
↳ e2e |
e2e lane (release or dispatch) |
every PR and every push to main |
|
↳ e2e-upgrade-plan, e2e-upgrade (shard) |
e2e_upgrade lane (release or dispatch) |
every PR and every push | |
| OS-specific tests | tests-os.yml |
python or desktop_updater or e2e or e2e_upgrade |
none of those |
| ↳ macOS (2 slices), Windows x64, Windows arm64 (2 slices) | always inside tests-os.yml; the real Windows hand-off tests only with desktop_updater |
||
| ↳ Windows install + update E2E | windows-install-update-e2e.yml |
e2e_upgrade (release or dispatch) |
PRs and pushes |
| ↳ Windows E2E (real processes) | e2e (release or dispatch) |
PRs and pushes | |
| Python lints | lint.yml (one job) |
python or frontend; the job itself skips a frontend-only push |
neither |
↳ Windows footguns + sibling guards, ruff check . (steps) |
python |
frontend-only | |
| ↳ code health ratchet (step) | PR (Python and TS) | push / dispatch / release | |
| ↳ ruff + ty diff (advisory steps) | PR and python |
push, or frontend-only | |
| JS & TS checks | js-tests.yml |
frontend |
no frontend change |
| Rust tests | rust-tests.yml |
rust |
no Rust change |
| Catalog tests | catalog-tests.yml |
catalog |
no catalog data change |
| Remote terminal backends | remote-backends.yml |
remote_backends |
no backend change |
| Bootstrap installer | bootstrap-installer.yml |
bootstrap |
no installer change |
| Desktop E2E | e2e-desktop.yml |
never (if: false, deferred as flaky) |
always |
| Desktop core E2E | e2e-desktop-core.yml |
e2e_desktop_core (release or dispatch) |
PRs and pushes |
| Desktop update E2E | e2e-desktop-update.yml |
e2e_desktop_update (release or dispatch) |
PRs and pushes |
| Docs Site | docs-site-checks.yml |
site |
no site change |
| Repository guards | repo-guards.yml (one job) |
always | never |
| ↳ No committed infographics / profile archives / case collisions / large blobs / lazy_deps imports / stale icons (steps) | always | never | |
| ↳ Deny unrelated histories (step) | pull_request only |
push, dispatch, release | |
| ↳ Check contributors (step) | python or catalog |
neither | |
| ↳ Check uv.lock (step) | uv_lock |
neither lockfile nor pyproject changed | |
↳ JS lockfile diff, pnpm-lock.yaml and package-lock.json (advisory steps) |
PR and npm_lock |
push, or no lockfile change | |
| ↳ Prompt surface diff (advisory steps) | PR and python |
push, or no Python change | |
| ↳ Lint Docker scripts (steps) | docker_meta |
no Docker meta change | |
| ↳ Supply-chain scan / PyPI upper bounds (steps) | PR and scan / deps |
push, or neither lane | |
| All required checks pass (v2) | inline | always() |
never |
| ↳ CI timing report (advisory steps of the gate job) | always(). Saves a baseline on main; makes a diff report on PRs |
never |
Steps inside one job keep what told the old jobs apart: the lane gate is the step's
if:, each guard runs whether or not an earlier one failed (!cancelled()), any red
step fails the job, and the review-status-* artifacts the live PR comment reads keep
their names. The jobs were split only by history: each paid its own checkout and a
full billed minute for a few seconds of work.
Not part of ci.yaml: the Docker image build, nix flake check, and the OSV
scan. Docker and Nix run in their own workflows. A reusable-workflow call
keeps the caller run "in progress", and GitHub refuses gh run rerun on a run
that is in progress, so one slow job would block reruns of the fast required
ones. OSV runs on a weekly schedule.
The gate: all-checks-pass
scripts/ci/required_results.py reads toJSON(needs):
- Regular mode (PR / push / dispatch without
release): any non-success that is not an allowed skip fails the gate. Skips are allowed, except for the lane-bound consumerstests,tests-os,e2e-desktop-update,remote-backendsandcatalog-tests: whendetectselected their lane, they must succeed. - Release mode: every required job must be
success. Only the deferrede2e-desktopmay skip. The PR-only checks are steps ofrepo-guards, which runs on every event, so they never reach the gate as a skip.
Branch protection requires only this check. The (v2) suffix is deliberate.
When a new job joins the required set, bump the version here and in the
protect-main ruleset together, so old greens on open PRs stop counting.
Release mode (inputs.release == true)
| Regular PR | Push to main |
Manual dispatch | Release (stable-release.yml) |
|
|---|---|---|---|---|
| Lanes | diff-selected | all except E2E | all incl. E2E | all incl. E2E (forced) |
| E2E suites | off | off | on | on |
| PR-only jobs | on | skip | skip | skip |
| Gate | lenient | lenient | lenient (strict with -f release=true) |
strict |
| Cancelled by a newer push | yes | orchestrator no; ref-keyed children (tests.yml, lint.yml, …) can be |
orchestrator no; unpinned children can be on the same branch, even with release=true |
no (run_id group) |
| Commit tested | PR merge ref | main head |
dispatched ref | claim commit via commit |
Triggering CI by hand
# Full CI on a branch, E2E included, lenient gate
gh workflow run ci.yaml --ref my-branch
# Same, with the strict release gate
gh workflow run ci.yaml --ref my-branch -f release=true
# Acceptance: let tracked known_failure gaps FAIL instead of xfail
gh workflow run ci.yaml --ref my-branch -f strict_acceptance=upd-txn
To get the Docker and Nix slow lanes on a PR without a dispatch, add the
run-e2e label (see label-rerun.yml). The label does not
turn on the E2E suites. Only a release or a dispatch runs them.
Standalone PR / main checks
Each of these has its own run and its own check. They are not required by branch protection.
| Workflow | Triggers | Runs / skips | Manual trigger |
|---|---|---|---|
docker.yml, Docker Build, Test, and Publish |
pull_request, push: main, workflow_call (stable) |
PR: mode (phase + classification, one job) → build only when the docker slow lane is on (Docker meta, dependency manifests, pm/, tests/docker/, webapp build recipe, or the run-e2e label). Builds slim+desktop × amd64+arm64 and runs the docker integration tests. Never pushes. Push to main on the upstream repo: always builds, then publish (env container-publish) and merge push :main / :main-desktop only (never :latest). Stable release-phase: test: builds the claim commit, tests it (unless the claim has no docker-tests lane), and archives the exact image. release-phase: publish: pushes the archived bytes under the attempt ref and returns the manifest digest. No release: published trigger, on purpose. |
none (gh run rerun) |
nix.yml, Nix flake check |
pull_request, push: main, workflow_call |
flake-check runs when the nix lane is on (nix/, flake.*, dependency/npm manifests, .github/, run-e2e label). Every push to main turns it on. release: true forces it. |
none |
pm-bundle.yml, PM Bundle |
pull_request on pm/** (not *.md), scripts/bundles/**, the Windows build deps, uv.lock, pyproject.toml, itself; workflow_dispatch; workflow_call (stable) |
Stages and smoke-tests hermes pm bundle on darwin-arm64, win32-x64, win32-arm64. Linux targets are commented out; there is no Intel macOS leg. |
gh workflow run pm-bundle.yml [-f ref=<sha>] |
termux-verify.yml, Termux verification |
push: main / pull_request on scripts/termux/**, pm/** (not *.md), tests/scripts/test_termux*, itself; workflow_dispatch; workflow_call (stable) |
contracts and tui always run. r2-provisioned is skipped on PRs. native-runtime (arm64) runs only when R2 secrets exist or release: true. A release run fails loudly without R2. |
gh workflow run termux-verify.yml |
plugin-catalog-ci.yml, Plugin Catalog CI |
pull_request on plugin-catalog/** |
structural (schema) and pinned-source-validate (the pinned SHA is reachable and the plugin passes hermes plugins validate; inner steps skip when no entry file changed). |
none |
sandbox-image.yml, Sandbox desktop image |
pull_request on its Dockerfile and smoke script; release: published; workflow_dispatch (publish) |
build+smoke on the upstream repo only. publish/merge (push nousresearch/hermes-sandbox:desktop) only on release or a dispatch with publish=true. Never on main pushes. |
gh workflow run sandbox-image.yml -f publish=true |
windows-bundle-sdk.yml, Windows bundle SDK |
pull_request on the Windows bundle and signing scripts plus their tests; workflow_dispatch |
Runs the real Windows SDK downloader on cold Windows runners. | gh workflow run windows-bundle-sdk.yml --ref <branch> |
PR helpers
| Workflow | Trigger | What it does / skip conditions |
|---|---|---|
ci-review-comment.yml, CI review comment |
workflow_run of CI, types: [in_progress] |
Polls the CI run and the sibling Docker run for up to ~50 min. Keeps one live review comment on the PR up to date. Always runs scripts from the default branch. Skips non-PR runs and PRs from forks. A first-time contributor's run starts it only after a maintainer approves it, because in_progress fires then. One poller per head repo+branch; a newer CI run cancels the old poller. |
label-rerun.yml, Label rerun |
pull_request: labeled |
Only for the run-e2e label. Cancels the head commit's ci.yaml, docker.yml and nix.yml runs if they are still going, then reruns them. Their detect reads the live labels, so Docker and Nix build. Exits if the PR head moved. The E2E suites stay off. |
Post-merge automation on main
| Workflow | Trigger | What it does / skip conditions |
|---|---|---|
js-autofix.yml, auto-fix lint issues & formatting |
push: main touching *.js/cjs/mjs/ts/tsx or npm manifests; workflow_dispatch |
generate-patch (no write access) runs eslint --fix. apply-patch (write access, never runs repo code) runs only when there are fixes. It pushes bot/js-autofix and opens an auto-merge PR. |
archive-inputs.yml, Archive pinned PM inputs |
push: main on pm/lock.json, pm/artifact-mirror.json, scripts/termux/runtime_libs.json, its script, itself; workflow_dispatch; workflow_call(sha) |
Verifies every pinned HTTP input and archives it to R2 (env release-signing). The archive step is skipped with a notice when R2 secrets are missing. |
deploy-site.yml, Deploy Site |
push: main on website/**, skills/**, optional-skills/**, plugin-catalog/**, itself; release: published; workflow_dispatch (skills_index_run_id, rebuild_skills_index) |
deploy-docs (GitHub Pages, upstream repo only) runs on every trigger. deploy-vercel (Vercel hook) runs only on release or dispatch, not on path pushes. Shared pages group, queued. |
skills-index.yml, Build Skills Index |
schedule 06:00 and 18:00 UTC; push: main on its script or itself; workflow_dispatch |
build-index runs on the upstream repo only. trigger-deploy dispatches deploy-site.yml with the new index, and only on schedule or dispatch (not on the path push). |
Scheduled monitors
| Workflow | Schedule (UTC) | Other triggers | What it does |
|---|---|---|---|
canary-release.yml |
daily 06:41 | dispatch | See Canary |
skills-index.yml |
06:00, 18:00 | push, dispatch | See above |
skills-index-freshness.yml |
every 4 h | dispatch | Upstream only. Opens an issue if /docs/api/skills-index.json is more than 26 h stale, missing, or a source collapsed. Also checks CORS on /docs/api/plugins.json. |
docs-search-monitor.yml |
every 6 h at :17 | workflow_run after every Deploy Site; dispatch |
Upstream only. Live Algolia and headless-browser probe of docs search. A red run is the alert. |
osv-scanner.yml |
Mondays 09:00 | dispatch | OSV scan of uv.lock, pnpm-lock.yaml and the nested package-lock.json files. Results go to the Security tab. Advisory (does not fail on vulnerabilities). Intentionally not per-PR. |
Releases
There are three release paths, and none of them starts from a tag push:
| Canary | Stable | One-off / channel build | |
|---|---|---|---|
| Entry | canary-release.yml (daily cron or dispatch) |
python scripts/release.py release … → dispatches stable-release.yml |
python scripts/release.py --build-commit REV [--channel NAME] --publish → dispatches desktop-bundled-release.yml |
| Identity | v<stable>+canary.<UTC> tag, separate hermes-canary app |
rc.<N>-vX.Y.Z attempt ref, then a vX.Y.Z receipt at publish |
commit: hermes-<sha7> app; channel: Hermes NAME or stable branding |
| Tests | native smokes of each bundle | full CI (strict) + Docker + Nix + PM bundle + install/update E2E + signed upgrade acceptance, per the claim's lanes | native smokes |
| Publishes | canary feeds, canary GitHub prerelease | Docker versioned tag → stable/latest, R2 stable feeds, App Installer, Store, APT, GitHub release |
commit/channel pages under R2; never a feed or Store |
Long-form reference: website/docs/developer-guide/stable-releases.md.
Canary releases
canary-release.yml, Canary Release
- Runs: daily at 06:41 UTC, or on
workflow_dispatchfrom the default branch. - Skipped: the
tagjob skips when dispatched from any branch other than the default branch (it would tag unmerged code).pruneskips whentagwas skipped or cancelled. - No new commits:
release.py --canaryresumes an unpublished canary until its protected head is live, or does nothing. - Flow:
tagrunsrelease.py --canary --publish, which pushes the tag, drafts the prerelease, and dispatchesdesktop-bundled-release.yml(a separate run).prunedeletes canaries older than 14 days (GitHub, plus R2 when secrets are present). - Manual:
gh workflow run canary-release.yml(onmain). - A queued dispatch that a newer one superseded ends as
cancelled. Onlyfailuresignals a canary failure.
Stable releases
stable-release.yml, Stable Release (run name Stable release <tag>)
-
Runs:
workflow_dispatchonly, from the default branch, withtag=<rc.N-vX.Y.Z>(the annotated claim ref) and an optionalbaseline-manifest. Usescripts/release.pyinstead of dispatching by hand. It derives the version, pushes the claim atomically, creates the draft, and dispatches:python scripts/release.py release --commit "$(git rev-parse origin/main)" --bump patch --remote origin python scripts/release.py release … --only windows,docker # lane subset python scripts/release.py release … --with termux --without install-e2e python scripts/release.py publish --version X.Y.Z --remote origin python scripts/release.py abandon --version X.Y.Z --remote originNever dispatch it with a final
vX.Y.Ztag. Never publish the draft from the GitHub UI. -
What runs is decided by the claim's lanes, not by inputs or paths.
admitchecks the tag object, the commit, and ancestry onorigin/main, then reads the lane set from the claim message (scripts/releases/lanes.py). It outputslanesandjobs. Every lane-controlled job hasif: fromJSON(needs.admit.outputs.jobs)['<job>']. A job whose lane is off reports skipped, and the gate requires that skip. A job that ran although its lane was off also fails the release.Lane (default) Jobs it controls ci(on)ci→ci.yamlwithrelease: truedocker(on),docker-tests(on)docker(test phase),publish-dockernix(on)nix→nix.ymlwithrelease: truepm-bundle(on)pm-bundletermux(off),termux-checks(on, only withtermux)candidates-termux,termux-checkswindows-live(on)windows-live→windows-venv-e2e.ymlinstall-e2e(on)install-e2e→install-e2e.yml(routeall)windows(on),windows-store(on)candidates-win32-{x64,arm64,bundle}, Store submissionmacos-arm64(on)candidates-darwin-arm64smoke(on)native smokes inside the candidate calls upgrade(on)transitions-*andwindows-packaged/macos-packaged-*(signed OLD→NEW). Also skipped when no published baseline exists.bootstrap-version(on)bootstrap-version -
Order: every gate and candidate starts right after
admit. CI does not block the signed builds.acceptanceis the single join that blocks publication. Thenpublish-dockerandpublish-bundles(desktop workflow,release-phase: publish),publication, and finallycomplete("Stable release is green"). -
Concurrency: group
stable-release, queued, shared with publication. -
Secrets: called workflows get
secrets: inherit, exceptci.yaml.
stable-release-publication.yml, Stable Release Publication
- Runs: on
workflow_runwhen Stable Release completes, or on a manual dispatch (version). - Skipped: on
workflow_run, thereconcilejob runs only when the stable run did not succeed, was itself aworkflow_dispatch, and came from this repo. A green run publishes from its own final job. A failed run gets its failed jobs rerun at once, at most twice (attempts 2 and 3). After that the claim is burned. - Manual:
gh workflow run stable-release-publication.yml -f version=X.Y.Z(this is whatrelease.py publishdoes). It is idempotent, so you can run it again after a partial failure.
Desktop bundles: desktop-bundled-release.yml
Desktop Bundled Release. This is the native build, sign, stage and publish pipeline for darwin-arm64, win32-x64/arm64 (+ universal bundle and Store bundle), and Termux. There is no Intel macOS (darwin-x64) build. The linux-x64/arm64 groups are disabled (dummy jobs).
| Mode | How it starts | Key inputs | Publishes |
|---|---|---|---|
| Canary tag | dispatched by release.py --canary from canary-release.yml |
tag=v…+canary.…, upload_release=true |
canary Windows/macOS feeds, Termux .deb, builds table, canary release |
| Stable candidate | workflow_call from stable-release.yml |
release-phase=candidate, jobs=<one group>, skip-tests / skip-signed / skip-store / windows-smokes-with-bundle from lanes |
stages to releases/tag/<attempt>/ only, plus receipts |
| Stable publish | workflow_call (publish-bundles) |
release-phase=publish, manifest-sha256 |
verifies candidate files; stable-store submits the Store bundle |
| Commit build | release.py --build-commit SHA --publish (default branch, maintainer) |
build_commit=<40-hex>, bundle_env |
releases/commit/<sha>/ page; no feeds |
| Preview channel | release.py --channel NAME --build-commit … --publish |
channel, branding |
channel head CAS after every smoke passes |
| Disposable namespace (tests) | manual dispatch | disposable_channel, build_commit, disposable_receivers |
allocation only, no native build |
Inside one run:
- Per-arch
*-releasevs*-commitbuild jobs are chosen byvalidate's outputs. The matching… resultjob collapses them. smoke-*calldesktop-bundle-smoke.yml. It needs a staged SHA and skips after a failedvalidate.publish-win32-updater/publish-darwin-updaterrequireupload_release=trueand nobuild_commit/channel.stage-receipt-*run only forrelease-phase=candidate.stable-*jobs run only forrelease-phase=publish.builds-tableruns only for a plain uploading tag build (canary).commit-builds-summaryruns only for commit builds.
Re-smoke an already staged artifact without rebuilding:
gh workflow run desktop-bundle-smoke.yml --ref <test-branch> \
-f platform=win32 -f arch=x64 -f format=msix -f sha=<built commit> \
-f commit-build=true -f public-base=https://hermes-assets.nousresearch.com/
Install & update E2E family
| Workflow | Triggers | Notes |
|---|---|---|
install-e2e.yml, Install & Update E2E |
workflow_dispatch; workflow_call from stable (route=all) |
"Can a user on a released version get to this commit?" pick-releases samples release tags (newest, oldest, spread; tag-count or install-ref). generate-matrix expands scripts/sandbox/generate-e2e-matrix.mjs into linux/windows/macos legs. The bundled legs need windows-bundle-manifest / macos-bundle-manifest and a matching route. Never runs on PRs, pushes, or a schedule. |
install-e2e-run.yml |
workflow_call |
Linux/OS-agnostic leg: real curl | bash install, then update. Unsupported method pairs skip (grey). |
install-e2e-windows-run.yml |
workflow_call |
Windows leg (Hermes-Setup.exe via AutoHotkey, install.ps1, app "Update now" under Playwright). Packaged-bundle arm only with install-method=packaged-app. Also called by stable for signed acceptance. |
install-e2e-macos-run.yml |
workflow_call |
macOS leg (script installs, Hermes-Setup.dmg, packaged-bundle arm). Also called by stable. |
install-e2e-red.yml, Install & Update E2E red tracker |
workflow_run of Install & Update E2E; workflow_dispatch (run-id, dry-run default true) |
Keeps one install-e2e-red issue in sync. The automatic path runs only when the watched run was a schedule event (see dormant paths). |
gh workflow run install-e2e.yml --ref main -f route=all
gh workflow run install-e2e.yml --ref main -f route=windows-desktop -f install-ref=v0.21.5
gh workflow run install-e2e-red.yml -f run-id=<id> -f dry-run=false
Other release-adjacent workflows
| Workflow | Triggers | Notes |
|---|---|---|
bootstrap-installer-build.yml, Bootstrap installer build |
workflow_dispatch (ref, pin_branch, pin_commit) |
Signed Hermes-Setup.exe (Azure Trusted Signing) and notarized Hermes-Setup.dmg. Uploaded as run artifacts only, never published. No caches. |
live-providers.yml, Live provider canaries |
push: tags v*; workflow_dispatch (filter) |
Live calls to real provider APIs, upstream only. Each case skips when its LIVE_*_API_KEY secret is missing. Tags pushed by GITHUB_TOKEN do not start it, and stable attempt refs (rc.*) do not match v*. In practice it runs on dispatch or on a v* tag a person pushes. gh workflow run live-providers.yml -f filter='openrouter-anthropic' |
sandbox-image.yml |
see standalone | Publishes :desktop on release: published or dispatch. |
deploy-site.yml |
see post-merge | The Vercel deploy runs on release: published or dispatch. |
Manual-only and iteration-branch workflows
These let you iterate on Windows behaviour without the rest of CI. Push to a matching branch, or dispatch:
| Workflow | Branch pattern | Dispatch | Other callers |
|---|---|---|---|
windows-venv-e2e.yml, venv-holder live E2E |
wine2e/** |
no | stable (windows-live lane) |
windows-install-update-e2e.yml, install.ps1 + hermes update |
wine2e-install/** |
yes (strict_acceptance) |
tests-os.yml (e2e_upgrade lane) |
windows-desktop-attach-e2e.yml, Desktop attach to a running gateway |
wine2e-desktop/** |
yes | none |
gh workflow run windows-install-update-e2e.yml --ref <branch> -f strict_acceptance=upd-txn
Full index
R = reusable only. PR/main = pull_request / push: main. paths = path-filtered.
| File | Name | Triggers | Called by |
|---|---|---|---|
archive-inputs.yml |
Archive pinned PM inputs | main (paths), dispatch, call | none |
bootstrap-installer-build.yml |
Bootstrap installer build | dispatch | none |
bootstrap-installer.yml |
Bootstrap installer | R | ci |
canary-release.yml |
Canary Release | cron daily 06:41, dispatch | none |
catalog-tests.yml |
Catalog tests | R | ci |
ci-review-comment.yml |
CI review comment | workflow_run(CI in_progress) | none |
ci.yaml |
CI | PR, main, dispatch, call | stable |
deploy-site.yml |
Deploy Site | main (paths), release published, dispatch | skills-index (dispatch) |
desktop-bundled-release.yml |
Desktop Bundled Release | dispatch, call | stable; canary/release.py (dispatch) |
desktop-bundle-smoke.yml |
Desktop bundle smoke | dispatch, call | desktop-bundled-release |
docker.yml |
Docker Build, Test, and Publish | PR, main, call | stable |
docs-search-monitor.yml |
Docs Search Monitor | cron 6 h, workflow_run(Deploy Site), dispatch | none |
docs-site-checks.yml |
Docs Site Checks | R | ci |
e2e-desktop-core.yml |
E2E Desktop core | R | ci |
e2e-desktop-update.yml |
E2E Desktop update | R | ci |
e2e-desktop.yml |
E2E Desktop | R | ci (disabled) |
install-e2e-macos-run.yml |
install-e2e macos leg | R | install-e2e, stable |
install-e2e-red.yml |
Install & Update E2E red tracker | workflow_run(Install & Update E2E), dispatch | none |
install-e2e-run.yml |
Install & Update E2E (reusable) | R | install-e2e, macos leg |
install-e2e-windows-run.yml |
install-e2e windows leg | R | install-e2e, stable |
install-e2e.yml |
Install & Update E2E | dispatch, call | stable |
js-autofix.yml |
auto-fix lint issues & formatting | main (paths), dispatch | none |
js-tests.yml |
JS Tests | R | ci |
label-rerun.yml |
Label rerun | PR labeled | none |
lint.yml |
Lint (ruff + ty) | R | ci |
live-providers.yml |
Live provider canaries | tags v*, dispatch |
none |
nix.yml |
Nix flake check | PR, main, call | stable |
osv-scanner.yml |
OSV-Scanner | cron Mon 09:00, dispatch | none |
plugin-catalog-ci.yml |
Plugin Catalog CI | PR (paths) | none |
pm-bundle.yml |
PM Bundle | PR (paths), dispatch, call | stable |
remote-backends.yml |
Remote terminal backends | R | ci |
repo-guards.yml |
Repository guards | R | ci |
rust-tests.yml |
Rust tests | R | ci |
sandbox-image.yml |
Sandbox desktop image | PR (paths), release published, dispatch | none |
skills-index-freshness.yml |
Skills Index Freshness Check | cron 4 h, dispatch | none |
skills-index.yml |
Build Skills Index | cron 06:00/18:00, main (paths), dispatch | none |
stable-release-publication.yml |
Stable Release Publication | workflow_run(Stable Release completed), dispatch | none |
stable-release.yml |
Stable Release | dispatch | release.py |
termux-verify.yml |
Termux verification | PR/main (paths), dispatch, call | stable |
tests-os.yml |
OS-specific tests | R | ci |
tests.yml |
Tests | R | ci |
windows-bundle-sdk.yml |
Windows bundle SDK | PR (paths), dispatch | none |
windows-desktop-attach-e2e.yml |
Windows Desktop attach E2E | push wine2e-desktop/**, dispatch |
none |
windows-install-update-e2e.yml |
Windows install + update E2E | push wine2e-install/**, dispatch, call |
tests-os |
windows-venv-e2e.yml |
Windows venv-holder live E2E | push wine2e/**, call |
stable |
Known dormant or inert paths
These match the YAML today and are listed so nobody mistakes them for live coverage:
e2e-desktop.ymlis wired intoci.yamlwith a bareif: false(deferred as flaky). Keep it a barefalse. An expression form once made GitHub's parser reject the whole ofci.yaml.- Linux desktop bundles (
build-linux-x64/arm64indesktop-bundled-release.yml) and the Linux targets ofpm-bundle.ymlare disabled. install-e2e-red.ymlupdates its issue automatically only when the watched run came from ascheduleevent, andinstall-e2e.ymlhas no schedule. Today only a manual dispatch does anything.archive-inputs.ymldeclaresworkflow_call, but nothing in this directory calls it.- Some file header comments are older than the "E2E only on release or
dispatch" rule. For example,
windows-install-update-e2e.ymlsays it runs on every push tomain. Thedetectgate inci.yamlis authoritative: the E2E lanes are off for every PR and every push. - The
push: tags/release: publishedtriggers (live-providers,deploy-siteVercel job,sandbox-imagepublish) do not fire for tags and releases that automation creates withGITHUB_TOKEN.