sync_venv took four mutually exclusive plugin kwargs (plugin_dirs,
extra_plugin_dirs, selection, staged_plugin) and venv_is_current two, with
runtime ValueErrors guarding the combinations. Replace them with a single
`plugins=` argument typed as one of pm.plugin_inputs.Members, Candidates,
Selection or StagedUpdate, so a conflicting request cannot be expressed.
The module also owns the worker wire encoding that client.py and worker.py
each duplicated.
pm.install.sync_venv is split into cohesive helpers (feature policy,
install lock, publication snapshot, target selection, commit) with the
same ordering, receipts and recovery; its CC drops from 54 to 16.
All in-tree callers and tests move to the new argument.
The workspace npm ci printed nothing until 'added N packages': builders
set CI=1, which turns npm's progress off, and the stage name only went
to the desktop UI's status file.
Print a line before npm ci, and pass --progress=true so a terminal gets
npm's spinner back (npm still shows it only on a TTY, so piped output
such as the desktop app's log stays clean). The flag stays out of the
receipt-keyed args, so existing installs are still reused.
`release.py release` gains two flags. They can be used together.
--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.
--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.
The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.
The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.
A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:
- The next version was derived from it, so the next cut would reuse the
version. It now takes the newer of the R2 head and the newest
published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
their publication pass, so a bundle-less release would re-advance
every 15 minutes. The head is now the newer of the R2 head and the
published release whose final tag binds the Docker stable alias
digest.
`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.
Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:
- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]
Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
Invoke-DownloadWithProgress runs Invoke-WebRequest in a separate runspace,
where an HTTP or DNS failure is non-terminating: EndInvoke returned normally,
the caller skipped the mirror, and Get-FileHash died on a file that was never
written. Rethrow the runspace's first error outside the unwrapping catch, so
the caller sees the same WebException/HttpResponseException it classifies.
`irm | iex` runs install.ps1 as text, which execution policy never
checks, but Invoke-InstalledHermes then dot-sourced runtime.ps1 from
disk. That is a file load, and the default Restricted policy (Windows
Sandbox, fresh machines) refused it right after "hermes command
installed". Load the helper from its text instead.
That failure hid a second one on the same path: the `$command` local
was shadowed inside Invoke-Native by its case-insensitive `$Command`
parameter, so `& $command[0]` invoked the scriptblock itself until the
call depth overflowed. Rename the local.
utf-8-sig exists to tolerate BOMs that Windows tooling adds to files
users edit. /proc and /sys files are generated by the Linux kernel, never
BOM'd and absent on Windows, so -sig there only muddies the read/write
policy. Switch every literal /proc/ and /sys/ read to utf-8 and teach
the footgun read rule that string literals starting with /proc/ or
/sys/ are exempt (user-edited files keep utf-8-sig).
tools/lazy_deps.py was not deleted; it survives as an old-updater stub
that raises or stops for relaunch. The job display name and the
checker/test prose claimed it was deleted, which sends readers looking
for a missing file. The display name is not a required status check
(main requires only "All required checks pass", which keys on job ids),
so rename it to "No production imports of the tools.lazy_deps stub".
Stable Release Publication ran every 15 minutes (96 runs a day, each
checking out full history, setting up node and buildx, logging into
Docker Hub, and taking the release-signing environment) only because the
sequencer held a failed run for a 15-minute backoff that the failure
event could never satisfy, so the cron was what actually retried.
Drop the backoff: the reconcile pass started by a failed Stable Release
reruns its failed jobs right away. MAX_ATTEMPTS burning, oldest-first
retry ordering, the attempt-entry check, and the needs_retarget repair
stay. The schedule trigger goes; workflow_run and workflow_dispatch
remain the recovery paths.
The shared stable-release concurrency group cannot deadlock: the rerun
waits as pending behind this job, and the sequencer only confirms the
new attempt is queued before it exits and frees the group.
On a fresh ARM64 Windows host, the venv sync first installs Visual
Studio Build Tools, Rust and a vcpkg OpenSSL to compile dependencies
that have no ARM64 Windows wheel (cryptography among them). The Visual
Studio installer ran with --quiet, so the installer printed nothing
for 20+ minutes after the tool lines and looked hung.
Print a line before each slow step, with what it is for and how long it
can take, and run the Visual Studio installer with --passive so its own
progress window shows.
The documented one-liner, iex (irm .../install.ps1), runs the installer
inside the user's own session. Fail ended with exit 1, so any failed
stage closed the user's PowerShell window.
Fail now throws. The two entry points own reporting and the exit code:
-Stage prints the reason, emits the -Json frame and exits 1, as before;
the full install exits 1 only when it runs from a script file, and under
iex it prints the reason, sets LASTEXITCODE=1 and returns. A scriptblock
literal's File tells the two apart: $MyInvocation.MyCommand.Path names the
caller's script under iex.
A bare version request lets uv pick an emulated x86_64 CPython on
Windows arm64 hosts ("support for the native architecture (aarch64) is
not yet mature"). The bootstrap interpreter then ran as win-amd64.
Request cpython-<minor>-windows-<arch>-none from the machine
architecture the scripts already detect, in install.ps1,
setup-hermes.ps1 and setup-hermes.sh (win32 only; POSIX keeps the bare
version so uv still picks the right libc variant).
The strict version read in docker_config_migrate is what keeps a list-root
config.yaml on the warn-and-continue path; only a probe covered it. Fold the
list-root case into the existing invalid-YAML test (reverting to the tolerant
read now goes red) and correct the comment about where the warning comes from.
migrate_config() and the docker boot script each parsed config.yaml twice:
check_config_version() coerced a missing `_config_version` to 0 and threw the
"was it present" bit away, so has_version_stamp() re-read the file to recover
it, guarded only by a call-order promise in its docstring. That promise did not
hold for the docker script, which used the tolerant check: a list-rooted
config.yaml read as "unversioned, not below the floor", ran the backup +
migrate_config() dance and exited 1 (base: floor warning, exit 0).
Factor the read into _read_config_version_stamp() -> (Optional[int], latest);
None means the mapping has no stamp. check_config_version() is a thin wrapper
(None -> 0) so its 10 callers see identical output. migrate_config() and the
docker script decide `unversioned` from that single read; the docker script
now does the strict read itself and leaves an unparseable or non-mapping file
alone with a warning and exit 0, matching its invalid-YAML posture.
has_version_stamp() is deleted. Docker tests that mocked the pre-check now
mock the new helper.
A config.yaml without _config_version reads as v0 and is exempt from the
support floor, so the first `hermes update`, profile clone,
`hermes doctor --fix` or docker boot ran every one-time migration step on
it. Installers seed config.yaml from cli-config.yaml.example, which had no
version, and targeted writers (`hermes config set`, /personality, the
TUI/Desktop config writers) never stamp one, so this is the normal state
of --skip-setup, non-TTY and Desktop (--non-interactive) installs. The
value- and absence-based steps then reset the personality, raised the
delegation caps, turned verify_on_stop off, shortened the curator windows,
dropped model_catalog.ttl_hours and enabled plugins the user had installed
but never enabled.
- A config with no _config_version now gets only the steps keyed on a
legacy key or identifier (LEGACY_KEY_STEPS), then the stamp.
- cli-config.yaml.example carries _config_version, so every seeded
config (install.sh, install.ps1, docker/stage2-hook.sh, doctor --fix)
starts at the current schema.
- docker_config_migrate.py no longer refuses a version-less volume with
the "predates version 12" warning; like migrate_config() it migrates
and stamps it.
(cherry picked from commit 97ba11e07009f633662b0c7fa8701aa5b441bd22)
Re-running install.sh / install.ps1 over an existing checkout (desktop
bootstrap and its update retry do this) falls back to
`reset --hard origin/<branch>` when a fast-forward fails, with no anchor for
the commits it drops. Park HEAD under refs/hermes-update-backups/, the same
namespace `hermes update` writes and prunes, and print the ref.
Each update started the user's Chrome binary with its own --user-data-dir,
a second instance of the same bundle that the Dock records as a new
recent-app tile. On macOS the outcome now goes through the existing
notification + next-boot result dialog.
Fixes#96374
A --depth 1 clone hides the release tag runtime identity is derived from
and cannot resolve a non-tip --commit pin or the ancestor guard; a full
clone downloads every tree and blob ever committed. --filter=tree:0 keeps
the whole commit graph and its tags and fetches trees on demand: 125M vs
77M for --depth 1 against GitHub, identity exact. The deferred-checkout
fallback uses the same filter.
route already means "which legs run"; a leg name is the most specific
route. Presets keep their meaning (all, the linux trio, windows-desktop,
macos-desktop, the bundled three); any other value selects legs by name,
so a leg name, a fragment of one, or the job name GitHub shows
("<leg> / e2e") runs just those legs. A route that selects nothing fails
instead of producing a green empty run.
The generator is now the one interpreter of route for source legs: the
linux/windows/macos jobs run when it selected legs for them, replacing
three hand-kept preset lists. Dispatch route becomes a string input (a
choice cannot take a leg name) and reaches the gen step through env, never
interpolated into the script.
Every leg installed a release tag and updated to HEAD, so nothing ran
HEAD's installer on an empty machine (where all four 2026-09-23
install.ps1 breaks lived) and nothing exercised the updater we ship
today -- tag legs run the OLD build's updater handing off to HEAD.
The generator appends a HEAD -> NEXT start after the sampled tags, so the
column runs wherever the update legs run (dispatch and stable-release).
NEXT is a reserved update ref: the drivers mint a child of the install
commit that adds one marker file, written to the object store only, which
the local bare clone carries into serve.git.
On Windows the HEAD leg takes every git.exe dir off PATH and installs no
remote get-url shim: Get-PinnedGit returns any git on PATH, so either one
skipped pinned-git staging. launch-from-spec's HEAD observer now uses the
driver's real git so it cannot poll '' forever on that leg.
The stable cut built its draft body after pushing the attempt ref, so a
body over GitHub's 125000-character limit failed with HTTP 422 and
burned the attempt. The body is now built first, and an oversized one is
refused before anything is claimed, naming --no-changelog.
--no-changelog was defined only on the top-level parser, so
`release.py release --no-changelog` was an argparse error and the flag
never reached the cut. The release subcommand now takes it, with a
SUPPRESS default so the top-level spelling is not reset.
Conflict resolutions and semantic fixups:
- tools/environments/base.py: main's hard-exit kill fence (kill a spawn the
fence missed, deregister from _live_foreground in a finally) wrapped around
pm-clean's output collector.
- pyproject.toml: pm-clean's marker list plus main's new `live` marker.
- hermes_cli/main.py: pm-clean runs startup recovery from hermes_bootstrap, so
the old early-recovery block stays gone; main's interrupted-pull restore
(auto-merged above it) runs right after bootstrap, as on main.
- hermes_cli/update_cmd.py: main's interrupted-pull marker now guards
pm-clean's first tree mutation (release-tag detach, ff-only, or reconcile)
and is cleared once git is done. The marker's target is the ref git actually
moves to (a release tag, not always origin/<branch>), since the restore
compares against it.
- hermes_cli/_early_recovery.py: restore `import subprocess`, which pm-clean
had dropped and main's auto-merged restore needs (NameError on the first
launch after a killed update; test_update_interrupted_pull red -> green).
- apps/desktop/src/i18n/{de,es,fr}.ts: main's new locales carry the full
settings.about block; trim it to `updates` as pm-clean's type and the other
overlays do (tsc: 27 errors -> 0).
- main's new e2e tests: `import yaml` -> hermes_yaml; wake-word import table
names pyopen_wakeword (pm-clean's wake-openwakeword extra); the anthropic
key-leak switch leg needs the SDK, and the api_server two-tenant test needs
aiohttp, both PM runtime extras the test env does not carry.
run_tests.sh starts every run from env -i with an allowlist, so the e2e
job's HERMES_E2E_REQUIRE_TUI=1 never reached the terminal suite (a missing
ui-tui build skipped instead of failing) and the upgrade suite's CI branch
in sandbox_required_reason() was dead. With ui-tui/dist removed and
HERMES_E2E_REQUIRE_TUI=1: before, 2 skipped; after, 2 failed.
generate_changelog() defined all_authors and teknium_aliases inside the
'if not no_changelog' block but read them after it. The canary tests
stub generate_changelog, so nothing ran the real path.
GitHub's generate-notes lists merged pull requests since the last
published release. A fork merges none, so the stable draft body was only
a "Full Changelog" link, and it lost the HERMES_BUILDS_TABLE marker that
the stable workflow renders the download tables into.
The cut now builds the body with generate_changelog() over the commits
from the published stable commit (or the seed version's tag before the
first publication) to the cut commit.
The stable cut already creates the draft on the claim ref before it
dispatches the gate, but the output pointed at releases/tag/<claim> and
then at releases/tag/v<version> "when it is green". GitHub serves a draft
only at an untagged-* URL, so neither link showed it. Operators read that
as "the draft appears after the build".
Take the URL gh release create prints (the release's html_url) and print
it up front, with a note that notes edited during the build survive:
edit_draft_release keeps the body and strips only the warning fence. The
v<version> URL stays, labelled as where the release lives once published.
The canary resume path had the same broken releases/tag/<tag> link; it
now uses the create output or the url field of gh release view.
Conflicts:
- scripts/releases/stamping.py, tests/scripts/test_version_stamping.py:
took ethie/pm-clean. The release branch's side was only its base's copy
of "stamping a payload snapshot skips the bootstrap-installer check"
(8411fdb333, same patch-id as 8d34601f47 here); the install-stamp
refactor c13ea774e6 supersedes the rest.
- tests/ci/test_stable_release_graph.py: kept pm-clean's release-epoch
contract (no HERMES_RELEASE_EPOCH on termux-deb, version on docker and
nix only) and the release branch's per-group receipt wiring.
Semantic conflict: the dispatch log step (6e64e961d8) read
inputs.termux_only, which the jobs input replaced. It reads JOBS now, and a
dispatch that selects only some groups has no release.py replay, as a
termux-only one had none before.
Prerequisites is the first stage, so on a fresh Windows host
<HermesHome>\tools does not exist when Get-PinnedGit runs, and Move-Item
throws DirectoryNotFoundException (reported against the source path).
The uv stager already creates its slot with New-Item -Force.
install.ps1 already kept uv's bootstrap Python out of the Windows
registry, but setup-hermes.ps1 did not, so every Windows dev checkout
registered that interpreter under HKCU. The flag is Windows-only; the
POSIX installers take it too so every bootstrap issues the same command.
PM prepares the ARM64 build environment again inside callers that already
carry a matching developer environment. It is reused as-is, including PATH
order, and a Git Bash caller puts coreutils' link.exe first, so the guard
aborted setup ("MSVC link.exe is shadowed by ...\Git\usr\bin\link.exe").
VsDevCmd's VCToolsInstallDir names the MSVC linker exactly, whichever way
the environment was obtained. PATH order stays the caller's.
installer-tests.yml predates nothing it still owned. Its pytest step
(test_source_launcher_stages.py) is platforms("windows") and already runs in
both tests-os Windows lanes, so every installer PR ran it twice. The
`installer` lane never gated anything on its own either: every path that set
it also sets `python`, which gates tests-os.
The two standalone scripts/tests/*.ps1 suites become one platforms("windows")
pytest file parametrized over Windows PowerShell 5.1 and pwsh 7, so
list_os_marked_tests picks them up with everything else. The `installer` lane
goes away from the classifier, detect-changes, ci.yaml and the
all-checks-pass gate; the classifier contract now pins that install.ps1 and
its suites turn `python` on.