READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.
Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).
The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.
User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
close_running_job() counted every finished step of the report's own job. On a PR the PR-only baseline restore finishes before collection, on main it never runs, so a slow cache download inflated the PR's gate time against main's baseline. ci.yaml now passes --own-job-last-step "Evaluate job results" and the gate's time ends there; steps after it are report work.
The timing report's own job has no log until it finishes, so fetching it listed the gate under "Unavailable job logs" in every report. Its timing entry stays; only the log download skips it.
test_status_functions_are_wrapped_so_yaml_keeps_the_negation could not see the case it described: ruamel reads an unquoted `if: !cancelled()` as an empty tagged scalar, which test_every_check_runs_whatever_an_earlier_check_did already catches.
Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
repo-guards.yml and lint.yml run many checks as steps of one job, so the comment's "Job **X** failed" item no longer said which check went red. split_failed_steps() reports each failed step of a failed job as its own entry, for the comment only: the poller's job counts and transitions stay per job, and a failed job with no failed step (a timeout) keeps its entry. The dependency-bounds failure step carries "supply-chain" in its name so the supply-chain review status still covers it, as the old job name did.
Patch by @BearHuddleston (review of #149786).
Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
The timing report moved into the all-checks-pass job, which the jobs API
still lists as in_progress while the report collects. collect_timings
dropped every unfinished job, so the gate vanished from every report and
from main's baseline, and wall time stopped at the last worker.
The job the report runs in (matched by RUNNER_NAME) now counts up to its
last finished step: the verdict, without the report's own steps, as when
the report was a job of its own. Other unfinished jobs stay out.
Thirteen guard workflows (fifteen jobs) ran on the same 4-vCPU Ubuntu
runner, all started right after detect, each paying a checkout and a full
billed minute for 0-13s of work: infographics, profile archives, case
collisions, blob size, lazy_deps imports, stale icons, unrelated
histories, contributor attribution, uv.lock, prompt-surface and
package-lock diffs, the supply-chain scan and dependency bounds, and the
Docker lint.
repo-guards.yml runs them as steps of one job. Each step keeps its old
lane gate in its `if:`, and runs whatever an earlier step did
(`!cancelled()`), so one red guard never hides another; any failed step
fails the job, which all-checks-pass requires. The review-status-*
artifacts the live PR comment reads keep their names. Files the steps
write go under $RUNNER_TEMP, and the guards that need no toolchain run
before setup-pm, whose dependency phase also checks the lock.
The four PR-only jobs the strict release gate tolerated as skips are now
steps of a job that runs on every event, so the only tolerated release
skip left is the deferred Desktop E2E.
The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.
Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
The surface-gate lookup matched the inner function by name and read its
first closure cell, so renaming it or capturing another value ahead of
field silently dropped or misrecorded every surface suffix. It now
compares against route_on's code object and reads the cell named field;
a rename of field fails with KeyError. The regenerated ledger is unchanged
(12 gated routes).
The authority serves connection.respond, connectors.connect and
connectors.operation.status/wake only for a session-owned call; an
account-owned one (the Desktop Connectors page) gets -32601 and ws.py
falls back to the legacy handler. The ledger recorded all four as
authority-owned. They now read "authority (session-owned); account-owned:
legacy fallback" and stay undecided (D3).
The refused set now comes from calling legacy_fallback_allowed with the
interactive capabilities, the same predicate ws.py calls, instead of
re-deriving it from the module's private tables.
The ledger is working data for the serve cutover, not user documentation, and
is deleted with the serve stack. Move it next to its drift test as
tests/hermes_cli/serve_handler_ledger.json, which Docker, Desktop payloads and
Nix already exclude; the generator and the test only change the path.
Creating a profile is host-level work, not session work, so POST /api/profiles
and profiles.create are recorded as keep-host-wide instead of undecided.
Add scripts/gen_serve_handler_ledger.py and the generated
website/docs/developer-guide/serve-handler-ledger.json: one row per
HTTP/WebSocket route of the app `hermes serve` runs and per JSON-RPC
method an /api/ws client can call, with its current path and an owner
decision.
RPC rows are classified with the tables /api/ws dispatch reads: the
authority's served verbs, then ws_legacy_fallback's refused/fallback
rules. A served verb is decided `authority`; every other row is
`undecided` (RPC fallback/refused rows reference D3) until a reviewed
decision is recorded in the script's DECISIONS table. Dashboard-plugin
routes depend on installed plugins and are one row, the hot-mount route.
tests/hermes_cli/test_serve_handler_ledger.py fails when a handler is
added or removed without regenerating the ledger; --check does the same
from the command line.
Refs #145171 (EP-1)
Publishing v0.23.0 committed the Microsoft Store submission, and Partner
Center answered POST .../commit with 202 Accepted and status CommitStarted.
The submission API helper only accepted 200, so the publication pass logged
"commit failed: 202 {'status': 'CommitStarted'}" and told the release owner
to check Partner Center by hand, even though the commit had started.
Any 2xx is now success. The fake Partner Center in the tests answers a commit
with 202, as the live API did, so the six tests that commit a submission fail
on the old check.
main already ships `hermes plugins update` (no name = update all) and the
opt-in `plugins.auto_apply` cadence. This makes both reachable from every
surface and closes the gaps reported on the original PR:
- Catalog installs now ride `plugins.auto_apply` too (it moved only git
rows, so catalog plugins stayed on their install-day pin). The update
path still refuses a widened pin or a too-new requires_hermes unattended.
- One updater per plugin: a cross-process lock around update_one and the
dashboard/Desktop update, so the gateway's auto-apply tick and a manual
update never run on the same plugin at once; the loser changes nothing.
- `hermes update` names, per profile, the catalog installs its new catalog
pins left behind and re-arms that profile's plugin check, so an opted-in
gateway applies them on the next tick.
- New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and
`/plugins auto-update [on|off]` in the classic CLI and the TUI (slash
worker).
- RPC `plugins.manage` actions `update_all` and `auto_update` (contract +
regenerated apps/shared outputs) for Desktop.
Off by default (legacy behaviour); all writes are profile-scoped through
get_hermes_home()/the scoped RPC.
With MS_STORE_HOLD set after a build was committed Immediate, the API
refuses the switch to Manual and the pass told the operator to select
Publish now, though the build goes live by itself once certified. A
refused switch now reads the committed mode: Immediate reports that it
publishes on its own and that the hold cannot apply.
Committing the Store draft at publication makes every certified build go
live by itself, with no way left to keep one back for a chosen release time.
Setting the repository variable MS_STORE_HOLD=true makes the publication
pass commit the draft Manual instead: it is certified and then waits for
Publish now in Partner Center, with a warning that says so. A rerun of a
held submission is a no-op and never flips it to Immediate. Unset keeps the
auto-publish default.
Also quote the real v0.22.7 refusal (state 'PreProcessing') and update the
stable-releases developer guide, which still described the held commit.
The green run committed the Store submission held (Manual) and the
publication pass tried to switch it to Immediate. Partner Center refuses
to update a committed submission (v0.22.7: 409 InvalidState, "Cannot
update the submission because it is in certification"), so every
release needed Publish now by hand.
stable-store now leaves an uncommitted draft set to Immediate, and the
publication pass commits it. Nothing reaches certification or customers
before the release is published, and the build goes live as soon as it
passes certification.
scripts/releases/desktop_targets.py holds the (electron platform, arch)
triple and derives the manifest spelling and the native handoff legs from it.
channel_artifacts, channel_publish, channel_releases, stable and the builds
table read it instead of five hand-written copies, and NATIVE_LEGS has one
definition instead of two.
read_native_receipts always reads every native leg, so its legs parameter
and _leg_targets go. A claim with includeMacosIntel: true now says why it is
refused. Tests iterate the targets directly instead of skipping a 2x2 case.
Moves the transitive body-parser copy (express ~1.20.5) from 1.20.8 to
1.20.9 in the website lock, and the whatsapp-bridge override pin that
holds it there. 1.20.9 was published 2026-10-22, inside the website's
14-day npm release-age gate, so website/.npmrc gets a dated
min-release-age-exclude entry. Remove it once 1.20.9 clears the gate
(2026-11-05). whatsapp-bridge has no release-age gate.
Advisory: GHSA-27r3-jxmh-p828 (high, DoS via trailing bytes after a
compressed body)
Review (BearHuddleston): _run_one_file_once copies os.environ before it calls
start_pytest(), which is where the first file pops the knob. So the first
file, and any others in the opening wave, still carried it: 1-3 files of 200
at -j 32 in every run. A test that starts the runner or run_tests.sh from one
of those files would silently run its nested suite under the fork server,
and _longest_first now puts the slowest cached files, the runner's own tests
among them, at the front of each slice.
start_pytest drops the knob from every file's env, on both launch paths. The
parity probe's first file asserts the knob is absent (it saw '' before), and a
24-file opening wave at -j 12 asserts no file sees it.
Review: if the cache file was never rewritten within the 20s wait, the step
still printed 'module analysis cache ready'. It now prints a warning with the
cache path instead; still non-fatal.
Review: the fork mode spread its server state, lock, process creation and
process-group rules through the runner, so _run_one_file_once had to know that
a forked child calls setsid() while a subprocess needs getpgid(). The runner
now calls start_pytest() and gets the process and its group back; the fork
server, its lock and the Popen shim live with it. Behaviour is unchanged.
classify_changes lists the launch module and the fork child as test harness
next to run_tests_parallel.py, so editing either starts the lanes a runner
edit starts (the fork child was missing since it was added).
Under the fork server multiprocessing still saw each file as the server's
child: current_process() was ForkServerProcess-N with a parent, every file
after the first inherited the runner's /tmp/pymp-* temp dir, and __main__ was
this runner, re-run in every child as __mp_main__ (~7-20ms per file). A
test's own Process, Pool or spawn context now sees what python -m pytest gives
it: a parentless MainProcess, a temp dir under its TMPDIR, and pytest as
__main__ and __mp_main__; the server preloads the runner once. The fork probe
compares these against a fresh pytest process, from the server's second child.
Also from review: run_tests_fork_child.run takes keyword arguments and one
argv; the start-under-lock is one helper the race test calls, and the test
starts 2000 children (unlocked, ~1 status in 400 comes back 255), so dropping
either lock fails it; kill() leaves a reaped pid alone; the output file is
removed when start() fails; the exit-status note skips exit 1 (tests failed,
already reported) and crashes; the 2.0s default duration is one constant; the
new tests reuse the file's runner helpers. The docstring records the gap left:
a daemon a test forks without exec escapes the leftover sweep.
main() is over the code-health ratchet's complexity and length budgets, and
starting the fork server there grew both. The first file now starts it under
the fork lock, before it copies os.environ, so the knob still never reaches a
test. Also: a timeout on the sys.path probe (HX006), a utf-8-sig read of the
child's output file, an unquoted annotation (UP037), and the Linux-only setsid
marked for the Windows footgun check.
Forwarding PSModuleAnalysisCachePath only helps when something already warmed
the cache. Whether the ARM64 build-deps step did depended on how long its
PowerShell stayed up: PowerShell writes the analysis from a timer ~10s after it
runs (AnalysisCache.QueueSerialization), so a process that exits right after
its first cmdlet never saves it. On runs where build-deps finished fast the
cache stayed cold and every PowerShell child a test spawned paid ~22s again
(arm64 test step 206-259s instead of ~130-150s).
Warm it explicitly from a cmd step and stay up until the cache file is
rewritten: ~39s when cold, ~1s when warm. Forced-cold check: a child launched
afterwards started in 0.4s and the test steps ran in 137/146s.
Git for Windows ships etc/mtab as a Cygwin system-file symlink, and PM's digest
covers those 38 bytes. @actions/cache restores the PM store with Git's MSYS tar
under MSYS=winsymlinks:nativestrict, which writes a symlink to a non-Windows
target (/proc/mounts) as a WSL reparse point (tag 0xa000001d). Python cannot
open that, tree_digest raised, PM's verification of the cached Git entry
failed, and every Windows job re-extracted the 56 MiB PortableGit archive.
Before PM verifies the store, recreate each WSL symlink with Git's own ln under
winsymlinks:sys. PM's check still decides whether the entry is intact.
multiprocessing's Process.start() polls every live child
(multiprocessing.process._cleanup). With the fork server, a child's exit
status is 8 bytes the server writes once on the child's status pipe. When a
worker thread starting the next file polled a child at the moment that child's
own thread called join(), one read got the status and the other got EOF, which
multiprocessing reports as exit status 255. A green file then failed with no
error (CI: tests/agent/test_openrouter_silent_default_purge.py, 5 passed, exit
255). Stress repro: 283 of 3000 children reported 255; 0 with this change.
Wait on the sentinel without reading, then join and read exitcode while holding
the lock that also serialises start().
A file can pass every test and still exit non-zero. Its failure dump showed
only the green pytest summary, so the cause (an exit-time hook, a status set in
sessionfinish, a signal) had to be rediscovered by rerunning the whole suite.
Append the child's exit status to the captured output.
The pool starts files in submission order, which was path order, so a slow
file late in the alphabet (tests/tui_gateway, ~40s each) ran alone at the end
of a slice while the other workers sat idle. Submit by cached duration,
longest first; the cache is already restored on CI for the timeout scaler.
The hosted Windows images point PowerShell at a shared module analysis cache,
which the job's earlier PowerShell steps (the ARM64 build-deps setup) warm.
run_tests.sh starts the suite from env -i and did not forward the variable, so
every powershell.exe a test spawned fell back to a cold per-user cache and
re-analysed every installed module (Az, AWS, Graph): about 22s per launch on the
arm64 runners. The three slowest Windows files (150-167s each) were almost all
that wait.
A fresh python -m pytest <file> spends about half a second starting the
interpreter and importing pytest and the third-party libraries nearly every
test file loads; over ~7000 files that is most of an hour of CPU per run.
With HERMES_TEST_FORKSERVER=1 (Linux CI) the runner starts one
multiprocessing fork server that imports those once and forks a child per
file. Each child still starts from a clean state: its own session, the
file's environment, python -m pytest's sys.path and argv, combined output and
pytest's exit code. Product code and tests/conftest.py are never preloaded.
Moves every setuptools pin from 83.0.0 to 84.1.0 together: the dev
group, [build-system].requires, and the Termux toolchain and
deb-wheelhouse pins (scripts/termux), so source builds, the lock and
the Termux payload keep using one version. Only setuptools moves in the
relock. 84.1.0 was published 2026-10-19, past the 3-day release-age
check.
This is a major bump. 84.0's removals are distutils compiler internals
(Compiler.spawn shims, exception classes, LooseVersion in
CygwinCCompiler). Hermes's setup.py and the setuptools.build_meta
backend use none of them.
Advisory: GHSA-xmj3-9gf7-h52w
Moves the transitive qs copy (via express and body-parser, both ~6.16.0)
in the website and whatsapp-bridge locks from 6.16.0 to 6.16.1.
6.16.2 is still inside the 14-day npm release-age gate (published
2026-10-27); 6.16.1 (2026-10-14) clears it and fixes the advisory.
Advisory: GHSA-6pq8-jhm2-2vcw
Remove the darwin-x64 build, receipt and smoke jobs from Desktop Bundled
Release and the candidates/transitions/packaged-x64 jobs from Stable Release,
with the release scripts that required them: the macos-intel lane, the
darwin-x64 job group, the x64 macOS target in candidate/channel manifests and
the two-leg macOS feed merge. The macOS updater feed is arm64-only.
Pre-lanes claims still read: the published ones all carry
includeMacosIntel:false; a claim with true is now refused.
pwsh-driven installer tests on Linux plant the system git, whose exec-path holds git-remote-https without .exe; the check called that git incomplete and tried to reinstall it.
Only the extract dir moves into the store root; the self-extractor still downloads to %TEMP%. Rejected bytes create no tools dir again (test_bootstrap_mirror::test_windows_bootstrap_rejects_corrupt_bytes_before_extract).
Windows PowerShell's Move-Item falls back to a file-by-file copy when a
directory rename is refused. When a file is held without delete sharing
(Defender scanning freshly extracted files, a lingering post-install
child), the copy stops at that file. cmd\git.exe has already landed in the
store by then and git-remote-https has not: this is the broken tree from the
Discord report. A windows-latest probe reproduces it on both same-volume
and cross-volume moves.
Stage the extract beside the entry under the store root (.staging-*, the
prefix pm gc already reclaims), so the last step is a single
[IO.Directory]::Move on one volume that moves all or nothing. Retry
moves and deletes refused by a temporary hold (1+2+4+8+16 s, like pm's
Store.publish). A hold that never clears now fails with a message
naming the folder.
A sharing violation during extract can leave cmd\git.exe in the store without git-remote-https. The next run treated that file as installed, and every https clone then died with "remote helper 'https' aborted".
Squirrel.Mac's ShipIt runs removexattr on every enumerated item of a
downloaded update before swapping it in, and aborts the whole install on
any errno except ENOATTR. A read-only file or directory the caller owns
returns EACCES, so one such entry wedges every macOS update (#138659).
record --platform macos already opens the update ZIP in every release
path that publishes a macOS feed (stable candidate, bundled canary,
channel build). It now refuses the ZIP when any entry with a Unix mode,
other than a symlink, lacks owner write, naming the count and the first
three paths. Entries without a Unix mode are skipped.
The check is the one @unsupportedpastels suggested in review of #148192;
@vadimcomanescu proposed a build check like it in #138659.
Refs #138659
The comments said writes to the baked pycs "still cannot land" and that
the macOS signature "never observes a pyc change". That holds for
default imports, which is all the launcher does. It does not hold in
general: with unchecked-hash pycs baked read-only (0444), a stale source
and Python 3.14.9, `--check-hash-based-pycs always` replaced the pyc (new
inode, mode 664), and `-O` wrote new opt-1 pycs beside it. CPython
replaces a pyc through its writable __pycache__ directory, so the
read-only bit never blocked either.
Say what actually keeps default imports off the pycs (unchecked-hash and
the asserted coverage), and note that 0o644 also stops a baked tree from
blocking the next prepare's rmtree on Windows (#137717).
Reported in review by @unsupportedpastels.
The shared-bundle-builds note and the stage_uv_cache docstring read as if
uv's wheel entries were symlinks on every target. On Windows uv writes
pointer files. The docstring's layout also covered only the pypi family;
URL, git and named-index entries carry an extra hash directory.
Reported in review by @unsupportedpastels.
stage_uv_cache copied the build cache with shutil.copytree's default
symlinks=False. uv stores each cached wheel as a relative link into
archive-v0/, so the copy stored every unpacked wheel a second time and
the seed then merged those directories into the machine cache. On the
published v0.22.0 macOS ZIP that is 30,859 files under
uv-cache/wheels-v6 (278 MB compressed, 901 MB installed), and uv fails
with "Is a directory (os error 21)" when it publishes one of those
wheels again (#141891). Windows is unaffected: uv writes pointer files
there.
Keep the links. The signing walkers already skip symlinks, so each
binary is still signed once at its archive-v0 path. Also correct the
shared-bundle-builds doc and the staging comment: built wheel ZIPs ship.
ShipIt strips the quarantine attribute off every file in the .app and
aborts the whole update with EACCES on any read-only one, so a payload
whose baked pyc files were sealed 0444 (f6713f2762) could never pass
a macOS update: three attempts, "Couldn't remove quarantine attribute",
then "Too many attempts to install, aborting update" and the old app
relaunches on the old version. Bake them 0644 instead: unchecked-hash
pycs are never rewritten and asserted coverage means no cache-miss
write can target them, so the read-only bit only ever blocked the
updater.
(cherry picked from commit fa34f089cb6e05bc86c03320eaf0d5e136b96f7d)
The signed Windows MSIX build fails every run since the [documents] extra
(b357ebbfc4) put python-docx into the bundled payload: its wheel carries
docx/templates/default-docx-template/[Content_Types].xml, and MakeAppx
refuses that package-reserved name anywhere in the layout with 0x8007007b
"The filename, directory name, or volume label syntax is incorrect".
The directory is the unzipped source of templates/default.docx; python-docx
only ever opens the .docx, so dropping the tree from the win32 venv and the
shipped uv-cache keeps Document() working. Any other reserved-name file now
fails payload staging with its path instead of failing the pack anonymously.