gh v2.99 gained a native --attach flag for issues, PRs and comments, so
the custom trusted-publisher chain (gh-image extension + GH_IMAGE_SESSION_TOKEN
workflow_run job + attachment-upload script) is superseded.
Remove:
- .github/workflows/publish-e2e-evidence.yml (workflow_run publisher)
- scripts/ci/publish_e2e_evidence.py + its tests
- e2e-evidence-* artifact upload + evidence staging in e2e-desktop.yml /
e2e_screenshot_status.py, incl. the 'inline evidence is publishing...'
marker placeholder in the CI review comment status
Keep: the review-comment screenshot/diff counts and artifact links produced
by e2e_screenshot_status.py.
The setup stage installs the gateway service through
ensure_gateway_service. On Windows that asks the start-now, Scheduled
Task and UAC questions. The gateway stage then ran `hermes gateway
install`, which asked them all again.
`gateway install --if-missing` does nothing when a service is already
installed. Both installers' gateway stages use it, so they ask only
when setup did not install the service.
Steps inherited the hand-off console's stdin, so any step that asks a
question blocked forever. Its prompt went to the captured stdout, which
is shown only after the step exits. `gateway start --all` did exactly
this: it saw an interactive console and asked "Install it now so the
gateway starts on login?". The update stopped after `hermes update exit
code: 0` and never relaunched the app.
Steps now read NUL. Prompts see a non-interactive stdin and take their
defaults. The working-directory self-test also checks that a step's
stdin is not a console, and a new test runs it under a real console.
powershell -Command joins trailing argv into the script text, so $args
was never populated and the path became a stray token (ParserError).
An env var needs no quoting.
record() looked up stamp['identity']['windowsExecutableName'], a key no
stamp writer emits, so every Windows record on a runner resolved no
executable and failed. The built package's Application/@Executable
names the exe; match it in the unpacked dir only, which also skips
before-pack's .bak rollback copy.
User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.
icons-freshness-check now regenerates and fails on any byte diff.
A non-elevated Windows ARM64 install threw "run setup-hermes.ps1 in an
Administrator PowerShell" whenever Visual Studio ARM64 C++/Clang were
missing. Interactive runs now launch the signed VS installer through a
UAC prompt; CI, ssh and scheduled runs keep the explicit instruction.
The flag used to exit 1 as retired. Now that PM installs the browser tools
by default, it maps to `pm.cli install --without agent-browser`, which later
installs and `hermes update` honour.
Updating an old checkout ran `git merge --ff-only`, which prints a
diffstat plus create/delete-mode summary. From v2026.7.1 to today that
is ~27k lines, emitted in under a second. Hermes-Setup.exe forwards
every stage line to its window as its own Tauri event; the burst
overflows the UI thread's Windows posted-message queue (10k), emits fail
with FailedToSendMessage, and afterwards clicking Launch can leave the
installer on "Launching" without ever spawning Hermes.exe.
This is why both Windows desktop-installer@latest E2E legs from
v2026.7.1 timed out waiting for the app window after Launch, while the
same routes from v2026.9.24 (small diff) passed.
Reproduced on Windows arm64 with the production Hermes-Setup.exe and a
protocol-faithful stage script: a 32k-line burst hangs Launch in most
runs (with ~14k FailedToSendMessage warnings), 5k lines and no burst
always launch. --no-stat reduces the merge to two lines.
pre no longer builds a bare serve.git mirror, moves refs or sets
allowAnySHA1InWant. The repo-local insteadOf now rewrites the official
URLs directly to --source, so updates follow the fork's main (force-push
it to the branch under test). post still undoes the redirect via the
clone/snapshot restore.
--ref is removed: neither hermes update nor the tester's pre-branch
desktop can follow another branch through config the kit could set
without the mirror. pre now ls-remotes --source's main before writing
anything, so a bad source aborts with nothing done. The stale
global-redirect cleanup is dropped: it only matched serve.git paths.
The Git-for-Windows archive ships dev/fd, dev/std{in,out,err} and
etc/mtab as symlinks into /proc. Without symlink rights (a standard
user, not elevated, no Developer Mode) inbox bsdtar cannot create them
and exits non-zero, so the bootstrap failed with "failed to extract
pinned git archive" before pm existed.
Exclude exactly the links pm's own extractor skips (extract_tar
git_msys); any other extraction failure, e.g. a truncated archive,
still fails. A test pins the installer's list to pm's skip set.
Install E2E never saw this: GitHub-hosted Windows runners run as an
elevated administrator, which holds SeCreateSymbolicLinkPrivilege.
The conftest split moved its platform gating and live-system guard into
tests/_fixtures/. tests/conftest.py re-arms the desktop-updater lane
because it owns that skip logic; the modules it now delegates to must
too, or an edit there skips the lane that exercises it.
The PM-clean installers dropped the old "restart your terminal" /
"source ~/.bashrc" closing line, so a fresh install ended on "Run: hermes"
in a shell that could not find it.
install.ps1 now also prepends the bin dir to $env:Path. That variable is
process-wide, so under the documented `irm | iex` path (and `& .\install.ps1`)
the caller's own window resolves `hermes` immediately. When run as a script
file whose inherited PATH lacked the bin dir (e.g. `powershell -File`, a
child that cannot touch its parent), the ladder ends with one arrow line:
restart the terminal, or reload $env:Path from the User and Machine values.
Setup/gateway already launch hermes through the resolved runtime command.
install.sh prints the equivalent "open a new terminal, or run: source <rc>"
line after the ladder when the inherited PATH lacks ~/.local/bin; the
installer is always a child and cannot change its parent's PATH.
Same presentation as install.sh, in the pre-pm installer's ASCII glyphs
(-> [OK] [!] [X]; PS 5.1 reads a BOM-less script as ANSI). Invoke-Logged
wraps the git, uv, pm and source-completion calls: on a console one status
line plus logs\install.log and a failure tail; CI, -Verbose or redirected
output (the -Json stage driver) stream through Out-Host as before. It keeps
Invoke-Native's contract: $LASTEXITCODE stays the caller's to judge, and
nothing reaches the pipeline, so value-returning functions can call it.
windows-build-deps.ps1 runs under pm (installer, hermes update) with the
console as stdout, so the vcpkg clone/bootstrap, the OpenSSL port build
(patch application and all) and rustup get the same status line, logged to
build-tools\build.log; discovery notes print only when streaming. -Verbose
exports HERMES_INSTALL_VERBOSE so that child streams too.
The pm-era installer printed "[hermes]" lines between raw git, uv and pm
output. Restore the pre-pm installer's look (→ ✓ ⚠ ✗, banner on the full
ladder) and route every child command through run_logged: on a terminal it
shows one status line rewritten with the command's newest output line
(git clone phases via --progress), appends everything to
$HERMES_HOME/logs/install.log, and on failure prints the last 20 lines plus
the log path. CI, --verbose, HERMES_INSTALL_VERBOSE and a non-terminal
stdout (the Hermes-Setup --json driver, E2E transcripts) keep the full
stream, so their parsers see what they saw before. Errors stay on stderr.
release.py was 2,804 lines, 2,076 of them the frozen legacy author dict.
The map and its resolver now live in scripts/releases/: authors.py holds
the directory loader, the merged AUTHOR_MAP and resolve_author, and
authors_legacy.py holds only the frozen LEGACY_AUTHOR_MAP literal (same
1,895 entries, same order). release.py drops to 707 lines.
The contributor-check job and audit_pr_attribution.py grep the legacy
file for quoted emails, so both now read authors_legacy.py. The
importers (contributor_audit.py, add_contributor.py), contributors/README
and the tests read the defining modules. No behaviour change.
tests/conftest.py had grown to 2,343 lines, past the 2,000-line gate. Move
three self-contained topics out, unchanged:
- env_filter.py: the credential / behavioral env-var name tables the
hermetic fixture blanks;
- live_system_guard.py: the autouse live-system guard fixture, its marks
and the protected checkout roots;
- platform_gating.py: the platforms() marker evaluation used by the
collection hook.
conftest imports them instead of listing them in pytest_plugins: it is not
the rootdir conftest, and pytest fails a run that loads a non-root conftest
carrying pytest_plugins after startup. Imported fixtures register on the
conftest module under their old names, so autouse order is unchanged.
Tests that reached into the moved symbols now import the new modules.
pre raised the shadow-storage cap to 128 GB and wrote the originals only
after the whole loop, so a pre that died mid-way left a raised cap with no
record for post to restore from. The cap must stay raised until post (it
is what keeps the snapshot alive during the update), so an exit trap is
wrong; instead record each original before raising it, and have status
list every recorded cap with the vssadmin command that puts it back when
post is never run.
The gateway stop the review flagged (`pkill -f "hermes gateway"`) is
already scoped: the .sh post runs `hermes gateway stop` under this
HERMES_HOME.
The full ladder runs every stage in one PowerShell process, and venv,
python-deps, source completion and launcher publication each called
Get-BootstrapPython, repeating the uv probe and `uv python find`.
Memoize the resolved interpreter in script scope. The existing
find-before-install order is unchanged.
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