- An untracked file whose bytes the base tree already holds (git hash-object
matches a base blob) is no longer an offender, matching what staging the
same copy already did.
- With an explicit --head, the allowlist comes from that revision
(git cat-file <head>:.github/blob-size-allowlist.txt; none = no
exemptions), so `--base A --head B` judges B by B's own exceptions.
Working-tree mode still reads the file from disk.
- Tests for both: untracked copies of a base binary and base text file pass
while an edited one is still caught; --head honours head's list over a
conflicting checkout, a head without the list exempts nothing, and
working-tree mode reads the disk list.
The guard judges a diff, so it gets the same base/head as the ratchet:
--staged judges the index, --commit the pushed tip, and a plain run the
working tree plus untracked files against the merge-base with
origin/main. `python scripts/check --only blob-size` reproduces CI.
Every blob a merge adds stays in every clone forever. In the last 90
days main received a 2 MB PR screenshot, a 2 MB default.tar.gz, three
PR infographics, a Windows cache database written by a test and a 1 MB
fuzz corpus at the repo root; nothing looked at file size.
scripts/ci/check_blob_size.py judges each path changed base -> head:
a binary (git's own --numstat detection) whose new content is over
500 KiB, added or modified, and any newly added file over 500 KiB.
Edits to existing text files stay with the code-health FILE_LINES
ratchet. Content the base tree already holds (a pure move, a copy)
adds no bytes and is not judged. Exact-path exceptions live in
.github/blob-size-allowlist.txt (CODEOWNERS-reviewed), seeded with the
one generated asset over the limit that legitimately changes.
It runs as its own unconditional workflow, like the case-collision
check, because asset-only PRs skip the Python and frontend lanes.
rc.12-v0.21.7 failed 'Submit the verified Store package' with
StoreError: unreadable submission status: ''.
store.submit parsed 'msstore submission status' as JSON. For a packaged
(MSIX) app that command prints a Spectre markup line ('Submission Status =
...') plus status tables, never JSON, and prints nothing when no submission
is pending (msstore-cli Commands/Submission/StatusCommand.cs). The first real
Store submission therefore always failed. 'msstore submission get' serializes
the submission as JSON (GetCommand.cs) and is already what submit reads after
the upload, so the in-flight check reads that instead.
The test fake now answers 'status' the way the CLI does; with it, main fails
5 tests with the production error.
Review findings on the abandon prompt:
- The prompt approved one ref but `abandon()` re-read the remote and cleared
every outstanding attempt of that version, so a concurrent operator's
replacement attempt could be cancelled and its draft deleted. The remote
is now read again after the answer, and anything other than the same single
attempt is refused; only that attempt is abandoned.
- Abandonment ran before the ancestry, final-tag and draft-body checks, so a
yes could destroy the old attempt and then refuse the replacement. Those
checks now run first; the prompt is the last step before the claim. The
attempt number ignores markers, so abandoning does not change the tag.
- The prompt test now varies stdin and stdout separately and asserts `input`
is never called unless both are terminals.
`release.py release` refuses while any attempt is outstanding and prints
the abandon command for the operator to copy. When stdin and stdout are
both terminals it now asks `Abandon <ref> and cut a new attempt? [y/N]`
right after that guidance. Yes goes through the same `abandon` as the
subcommand (cancel runs, delete the draft, write the marker) and the cut
continues as the next attempt; the default is no, and anything
non-interactive still refuses as before.
The confirmer and `inspect` are parameters of `release()`, not globals,
so only the CLI entrypoint opts in.
Review nits from #136602: the sshd and docker-backend image moves to the
current stable debian:13-slim (digest-pinned index), and the inline bash -c
setup becomes scripts/ci/test_sshd_container_setup.sh, mounted read-only and
run in the container. The new script joins the remote_backends path gate.
The lane's skip check grepped the progress line for an "<n>s," suffix, but
_print_progress prints skips before expected failures, so a skip beside the
SSH suite's xfail ("23✓ 1s 1xf, ...") passed unnoticed.
- run_tests_parallel.py: new --fail-on-skip. After the run, any parsed
pytest skip count fails the exit code and lists the skipping files, the
same way the zero-collected guard does; no display format involved. The
skip and failure reports move out of main() into helpers (health ratchet).
- remote-backends.yml: runs scripts/run_tests.sh --fail-on-skip instead of
tee + grep.
- Tests: a skip next to a strict xfail passes without the flag and fails
with it; a skip-free run passes with it; the workflow test pins the flag.
The SSH backend's real-host tests skipped on every CI run (nothing set
TERMINAL_SSH_HOST), and every Docker backend test mocked subprocess or the
docker CLI, so no CI run had executed either backend end to end.
- remote-backends.yml: starts a throwaway sshd container on 127.0.0.1 with a
per-run keypair (scripts/ci/start_test_sshd.sh) and runs
test_ssh_environment.py plus the new test_docker_backend_real.py against it
and the runner's Docker daemon with a digest-pinned debian:12-slim. A file
that skips fails the job, so the lane cannot go green over zero coverage.
- New classifier lane `remote_backends` (tools/environments/, tools/terminal_tool*,
the real tests, the sshd harness, run_tests.sh). ci.yaml calls the workflow
only when it fires, plus every push to main; required_results makes a selected
lane mandatory, like the update consumers.
- The real tests read HERMES_TEST_SSH_* / HERMES_TEST_DOCKER_IMAGE (forwarded
by run_tests.sh), because the hermetic conftest blanks TERMINAL_SSH_KEY as a
credential before any test can read it.
- New real SSH cases: exit code/stderr, cwd persistence with spaces vs per-call
workdir, quoting across the ssh hop, SendEnv passthrough through a real
AcceptEnv, file tools write/read on the remote.
- New real Docker cases: exec + exit codes, cwd vs workdir, exported env
persistence, docker_env/docker_forward_env reach the container and
nothing else does, quoting, file tools write/read, /root surviving a
container replacement.
- The existing one-shot test is xfail(strict): TERMINAL_SSH_PERSISTENT=false
is read into ssh_config["persistent"] but SSHEnvironment never consumes it,
so every SSH session persists exported env. Its _cleanup also cleaned the
raw task id, not the resolved env key, so one test's env leaked into the next.
- Retiring a preview channel now requires the stable head to carry every
package the preview ships. An Intel preview could otherwise be pinned to an
arm64-only stable head it has no package in, and its client would fail to
resolve the retirement for good.
- The docs and release.py's next-steps text said Intel Macs stay on the last
Intel release's feed. They do not: promote rewrites the one shared macOS
stable feed with the arm64 leg, so Intel Macs get no update until a release
opts Intel back in. Only the Termux APT channel stays on its previous release.
- New scripts/releases/optional_platforms.py holds the claim flag, jobs,
handoff, target and smoke of each opt-in platform; stable.py, channel
releases, release artifacts and the entrypoint read from it. stable.py is
back under 1,000 lines. No behavior change.
- Test: a macOS Intel receipt against a baseline without Intel plans no
upgrade arm (baseline=none); against a baseline with Intel it plans one.
Stable releases no longer build the macOS Intel (darwin-x64) desktop bundle
or the Termux APT package unless the release is cut with an opt-in flag.
Intel macOS is not supported on main, and its macos-15-intel bundle leg
takes 76-88 minutes, which is most of the release's wall time.
python scripts/release.py release --commit SHA --include-macos-intel
python scripts/release.py release --commit SHA --include-termux
The flags are written into the signed claim as includeMacosIntel and
includeTermux, like skipBundles/skipTests. admit emits them, and the
candidates-darwin-x64, transitions-darwin-x64 and macos-packaged-x64 jobs
(Intel) and candidates-termux and termux-checks (Termux) run only when
their flag is on. The gate requires those jobs to report skipped when the
flag is off; the skip flags keep their meaning.
Claims cut before these keys existed (every rc.* tag so far) have neither
key. validate_claim treats a missing key as false and still refuses a
non-boolean value or any unknown key, so sequencer discover/reconcile keep
working over the historical tags.
Without the opt-in platforms the candidate manifest, receipts, mac feed,
protected R2 head, APT promotion and downloads table all work with those
rows absent: the macOS feed carries the arm64 leg alone, the APT indexes
are left on the previous Termux release, and the unbuilt smoke is recorded
as skipped (a passed smoke for an unbuilt target is refused). A release
that opts Intel back in after one that left it out has no Intel baseline,
so its Intel upgrade arm is skipped like a first release.
Preview channels, canary and the PM bundle artifacts are unchanged.
* feat(code-health): HX013 blocks new wall-clock sleeps in tests
A fixed time.sleep()/asyncio.sleep() is a bet that the code under test finishes
in time; on a loaded runner it loses and the test flakes. Main has ~1,470 of
them in tests/ and nothing stopped new ones (HX rules exclude tests).
HX013 runs only on test paths (Rule.include) and flags time/asyncio/anyio/trio
sleeps with a delay that is not a literal 0, plus a fresh threading.Event().wait(x).
Existing sleeps are grandfathered by the ratchet's base-relative compare; moved
code keeps its debt; '# health: allow HX013 -- <why>' waives a deliberate delay.
The fix text points at tests/waiting.py (wait_until / async_wait_until), a shared
poll-with-deadline helper replacing the ~60 private copies across the suite.
* fix(code-health): HX013 passes the interval of a poll under a deadline
The frozen replay (PRs merged 2026-10-08..09) hit three private poll helpers
(while not done and monotonic() < end: sleep(x)): that is the compliant shape the
fix text recommends, not a fixed delay. A while loop that reads a clock and can
end on a non-clock condition now exempts its sleep; a loop that only waits out
the clock, or polls with no deadline, still counts. tests/waiting.py needs no
waiver any more; tests/AGENTS.md names the rule and the helper.
* fix(code-health): HX013 passes a round-bounded poll loop
The second frozen window (2026-10-06..07) hit 'for _ in range(200): await
asyncio.sleep(0.01); if idle: return', a poll bounded by its round count: load
stretches it but cannot make it return early. A for-range loop whose body can
leave on a non-clock condition now exempts its sleep; a range loop of fixed
delays, or a poll over arbitrary items, still counts.
* fix(code-health): HX013 exempts only a real poll's own interval
Review fixes for the HX013 poll-loop exemption, plus a file split:
- Split: the HX013 checker moves to scripts/code_health/py_rules_sleep.py,
and the AST helpers it shares with py_rules (Ctx, _FUNCS, _dotted,
_call_name, _deferred_parts, _eager) move to a leaf module py_ast.py.
py_rules.py drops from 1,034 to 877 lines (928 on main); CHECKERS still
registers HX013.
- Clock-only conditions: only a test operand that reads something other
than the clock counts as a condition. Combined clock checks
(`monotonic() < a and monotonic() < b`), `not`/constant operands, and
locals assigned only from clock arithmetic (`now < end`) no longer
exempt the sleep. `and`/`or`/`not` follow their logic, so `while not
done() or monotonic() < end` still waits out the clock.
- Loop ownership: a `break` ends only the loop whose body holds it (a
nested loop's break does not count for the outer loop; one in the
nested loop's `else:` does). Nested defs, lambdas and classes are
skipped. A sleep in a loop's `else:`, `while` test or `for` iterable is
not that loop's interval.
- Retry polls: a `try` body's return/break counts as conditional when the
body calls something that can raise (`try: return probe() except
OSError: sleep`). A body that only sleeps still counts as a fixed delay.
On origin/main this flags 2 more grandfathered sleeps and exempts 12 retry
polls the old rule missed, so the count goes from 923 to 913.
rc.11-v0.21.7's Windows x64 MSIX smoke failed with 'Artifact checksum
mismatch' on the 2.6 GB package. The stored object was correct (re-downloaded:
size and SHA256 match the receipt exactly), so the runner received bad bytes.
_download_url retried HTTP and connection errors but raised on a completed
body that failed its hash, so one bad transfer failed the job and its
dependents until a whole-run rerun.
A size/SHA256 mismatch now counts as a bad transfer and is fetched again
within the same three attempts. A stored object that really differs from its
receipt fails all three the same way, and the verified destination is never
replaced.
Review follow-ups: `providers.<id>.models.<model>.timeout_seconds` now
matches (a second <...> segment no longer ends the pattern), the YAML
warning filter is scoped with catch_warnings, and the slash-command list
drops a `hidden` filter CommandDef never had.
DEFAULT_CONFIG changed 183 times in 30 days and nothing checked that a new
key reached the docs; /pause (the global emergency stop) went undocumented
for two months.
scripts/ci/check_docs_drift.py lists every DEFAULT_CONFIG leaf and every
registered slash command and finds the ones the docs never mention. A key is
documented by its dotted path, a placeholder path in inline code
(`auxiliary.<task>.timeout`, `display.pet.*`), or a YAML example nesting it
under its parents; a slash command by a `/name` row in
reference/slash-commands.md.
The 132 keys undocumented today are baselined in docs_drift_baseline.json;
tests/ci/test_docs_drift.py fails only on NEW gaps, so it never blocks an
unrelated PR, and documenting a baselined key never fails. Deterministic,
stdlib + repo imports, ~1s.
/pause is documented here, so the slash-command baseline starts empty.
contributing.md says what to do when the test fails.
The admission gate ran the install scanner (dangerous fails) but nothing refused
a compiled binary, committed bytecode, or an archive hiding either — the shapes
the scanner cannot read. This adds that enforcement to pinned-source-validate.
- scripts/scan_plugin_payloads.py (stdlib-only, so the no-install validator and
third-party CI can run it): flags compiled binaries by magic header (ELF,
Mach-O, PE/MZ, WebAssembly — not by extension, which a rename defeats),
.pyc/.pyo bytecode (incl. sourceless/hash-forged caches that run instead of
the .py beside them), and archives whose members are not reviewable source.
Plain-text source bundles and inert assets (fonts, images) pass.
- plugin-catalog-ci.yml: after `hermes plugins validate`, each changed entry is
scanned; a finding fails it. An entry may list `attested_binaries:` paths,
each verified with `gh attestation verify --repo <repo> --source-digest <sha>`
against the pinned commit; verified paths are dropped before the scan, any
other binary still fails. Repo-controlled filenames are newline-stripped in
the log so none can forge a workflow command.
- validate_plugin_catalog.py: `attested_binaries` is a known key, shape-checked
as a list of relative paths.
- Tests: unit tests for the detector and two new workflow-driven bypass cases
(bundled .exe, committed .pyc) in the confinement harness, which runs the real
`run:` block.
Full-content re-sweep of all 543 pins: only tabro carries a native binary (it is
delisted in the companion PR). Three entries (crawl4ai, hermes-vision-inline,
mermail) commit __pycache__/*.pyc build litter and would fail rule 17 on their
next pin bump — pre-existing, not touched here.
rc.10-v0.21.7's "Stage verified stable candidate artifacts" job failed with
ValueError: Built package provenance does not match the release
assemble() ran stamp_matches() on each metadata row. stamp_matches() is the
check for an install-stamp.json and requires baseVersion, but a metadata row
is what record() writes (platform, arch, tag, commit, identity, version, ...)
and never carries baseVersion. So every bundled stable release failed here
once all packages were built. record() already verified each package's own
stamp (including baseVersion) when it wrote the row.
The row check is now what the row actually contains: commit and tag. The
staged-candidate fixture wrote a baseVersion into its rows, which is how the
mismatch stayed hidden; it now writes exactly the keys record() does. With
the corrected fixture, main's code fails 8 tests with the production error.
The prompt-surface-review ruleset was meant to require one other reviewer;
it is now set to one hermes-agent-core approval. CODEOWNERS, the CI comment,
the staleness-test message and the developer docs said two.
A one-line code change can rewrite what every session sends to the model -- a
guidance constant moving tiers, a toolset gaining a tool, a dynamic rewriter
editing a description -- without the diff showing it. The rendered prompt
surface is now committed and reviewed like code.
- scripts/ci/prompt_surface.py renders the system prompt
(build_system_prompt_parts) and the agent's tools[] through the production
code for nine surfaces (cli x claude/gpt/gemini/hermes, cli in a code
workspace, desktop, telegram, discord, cron) into
tests/fixtures/prompt_surface/. A fresh home in a child process; clock,
host OS, network, tool availability, env probe and machine paths pinned,
so a laptop and CI produce the same bytes. Each tool schema is stored once,
with a per-surface copy only where a surface serves a different schema.
- tests/ci/test_prompt_surface.py fails when the committed snapshot is stale,
so a surface change must carry the regenerated snapshot in the same PR.
- prompt-surface-diff.yml (advisory, PR-only, stdlib-only, no PR code runs)
posts per-surface token deltas, prompt hunks and per-tool
description/param changes into the unified review comment.
- CODEOWNERS requests hermes-agent-core on the snapshot; a new
prompt-surface-review ruleset requires two of their approvals.
- Docs: prompt-assembly.md (how it works), contributing.md (what to do when
the test fails).
Review on #136434 (@jquesnelle): discover() sorted records by version only,
so attempts inside a version kept git ls-remote's NAME order (rc.10 between
rc.1 and rc.2). reconcile indexes records by version with the last one
winning, so once a version reached rc.10 it handed rc.9 (burned, no release)
to publish_attempt, which refuses with "Final tag release database ID is
invalid". v0.21.7 is at rc.9, so its next cut is exactly rc.10.
- Sort by (version, attempt).
- The test fakes list refs sorted by name, as git does; insertion order hid this.
- rc.10 test (fails without the sort), and a same-commit retry test (rc.1
abandoned, rc.2 re-cut at the same commit wins).
Every Stable Release Publication run since v0.21.6 has crashed in
sequencer.discover():
ValueError: v0.21.6 points at a different commit than rc.1-v0.21.6
v0.21.6 took four attempts: rc.1-3 were abandoned (each has its
abandoned-rc.<N> marker) and rc.4 won and became v0.21.6. discover() tested
"does the final tag exist" before "is this attempt abandoned", so for rc.1 it
compared v0.21.6's commit (rc.4's) with rc.1's and raised; the abandoned
branch was only reachable for versions that never got a final tag. Any
version whose winner was not rc.1 broke every later reconcile, including the
next release's publish.
The abandoned marker is now checked first: an abandoned attempt is burned
whatever its version became. A NON-abandoned attempt whose final tag points
elsewhere still raises.
The posix hand-off hands marker line 1 to a custodian subshell
(marker_refresher_start) and then exported HERMES_UPDATE_HANDOFF_PID="$$" —
the hand-off script's own pid.
`hermes update` adopts a live claim only when the owner is its own pid, the
HERMES_UPDATE_HANDOFF_PID value, or an ancestor
(update_lock.UpdateLock._is_partner). The custodian is a *sibling* of the
hand-off script, so it is none of the three and the ancestry walk can never
reach it: every desktop-initiated update refused the claim its own script had
just made and exited 2 ("Another Hermes update is already running ...
process <custodian>"), leaving the Desktop to relaunch and retry forever.
Export $MY_PID instead: the custodian once the handover succeeded, and the
script's own pid when it did not.
Fixes#134268
Review thread on #136050 (agent/conversation_compression_telemetry.py,
"Refusals inflate batch counts"). Since #135807 every exit of
`compress_context` (blocked by a guard, skipped, aborted, refused) logs one
`context compression attempt telemetry:` line, and
`scripts/micro_compaction_report.py` counted every such line as a batch
compaction. Keep only records whose `commit_status` is `committed`.
The builder shipped ~1.9k skills.sh rows per run with no resolved_github_id.
Most were rows whose repo 404s or whose fully-read tree has no matching
SKILL.md dir (renamed/removed upstream); every client install of one spent
~40 GitHub calls and failed as stale_index.
- fuzzy match ignores both '-' and '_' (pdb-database slug vs pdb_database dir)
- a single-skill repo (root or generic skills/ SKILL.md) resolves like the client does
- repo 404 or complete tree with no match: the row is dropped, counts logged
- truncated tree: bounded contents/<candidate>/SKILL.md probes, row kept if none hit
- transient errors keep the row unresolved, as before
A release that skipped bundles never writes the stable baseline pointer,
so its absence is normal at any point, not only before the first bundle
release. Drop the final-tag scan. A 404 on the default pointer now always
plans no upgrade arm. A supplied baseline-manifest that does not exist
still blocks, because that is a bad input.
No stable release has shipped signed packages yet. Every final tag so far
skipped bundles or predates the claim pipeline. So
releases/stable/release-candidates.json does not exist, and the three
"Pin actual OLD and NEW signed packages" jobs refused with "No published
stable package baseline".
transitions now treats a 404 as a first release only when no final stable
tag records a candidate manifest and no baseline-manifest input was given.
It still validates the receipt, emits empty matrices and baseline=none.
The packaged upgrade arms skip on that output. The gate accepts their skip
only when their own planner succeeded with baseline=none, so a missing
baseline after a real bundle release still blocks.
`_kill_tree` SIGKILLs the per-file pytest process group, which misses any
child that left the group with `start_new_session=True` (an auto-started
`gateway run`, a setsid server). Those are reparented to init and run
forever: one host accumulated 215 orphan gateways (~52 GB RSS, up to 21 h
old), and every `hermes update` listed each as "another Hermes home — left
alone".
After every attempt the runner now SIGKILLs any process whose environment
still points into that attempt's unique temp root (PYTEST_DEBUG_TEMPROOT /
TMPDIR, or a HOME / HERMES_HOME a fixture created under it). The root is
unique per attempt, so the match names only that attempt's processes.
Linux-only (/proc); the process-group kill stays the fallback elsewhere.
Pull requests and pushes to main no longer run any E2E suite: ci.yaml's detect gate
forces every e2e* lane off unless the run is a stable-release run (inputs.release) or a
manual dispatch, whatever the diff, the update-path classifier or the run-e2e label
selects. That covers the Python e2e and e2e-upgrade jobs, the Windows E2E and Windows
install + update journey in tests-os, and the Desktop core/update suites.
install-e2e.yml drops its pull_request (path-filtered `pr` route) and 12-hourly
schedule triggers; it runs from the stable-release gate (workflow_call) or by dispatch.
live-providers drops its nightly schedule and keeps the release-tag push + dispatch.
Why: a one-row change to left_core_migration.py held a PR for 40 minutes on the
Windows install + update E2E; the suites cost most of the larger-runner minutes and
the release gate already runs all of them strictly.
Vite inlines every VITE_* variable into the renderer and NODE_ENV selects its mode, but the
desktop receipt recorded only sources and prepared inputs. After `VITE_PERF_PROBE=1 npm run
build`, the next plain build reused (and productCurrent certified) the renderer that still
carries the perf probe; a NODE_ENV change had the same gap.
The desktop build inputs now record the renderer environment (VITE_* plus NODE_ENV, unset
read as vite's production default), so both rendererCurrent and productCurrent require it to
match. Reported by Greptile on #135092.
rendererCurrent and productCurrent share hostReceipt() for the schema/product/host checks. The stamped outputs come from bundleElectronMain's own electronOutputs list, so a renamed or added bundle can never be copied stale from the previous dist. The reuse copy skips node_modules/ and native/, which copyNativeTree recopies from nativeDeps right after, and a --typecheck build never reuses a renderer that skipped tsc.
With --no-checkout the file download moved out of the retried clone into the single unretried pin checkout, which ran after the empty clone was already published: a throttled pin left INSTALL_DIR with no index, and the next rerun stashed a 'delete every file' autostash. The pin now runs in the staged tree inside the deferred-checkout retry loop (which also stops materializing the tip first), and nothing is published until it succeeds.
Mirrors the install.sh change from #135092 on Windows. With -Commit (what
the desktop bootstrap passes), the fresh clone runs with --no-checkout and
the pin is checked out with retries inside the staging dir, so the branch
tip is never materialized first and a refused or failed pin never publishes
a checkout. A rerun over an existing checkout keeps pinning after the fetch.
The tagger-line lookup indexed into a single string, so the epoch was the
character 't'. A -Python path that does not exist left LASTEXITCODE at 0, so the
script printed done and listed a previous build's package.
Review findings on the local build scripts:
- Cleanup ran before desktop.py took the checkout lock, so a second run deleted
the outputs of a build that was still going. desktop.py gains --clean, which
removes this checkout's outputs after build_lock is held. Both scripts pass it
instead of deleting files themselves.
- build-bundle.ps1 left RELEASE_CLAIM_* and HERMES_PAYLOAD_* in the caller's
session. It now saves those variables and restores them in the finally block.
- The scripts said "unsigned" but inherited signing credentials. The ps1 removes
AZURE_* for the build. The sh removes CSC_* and APPLE_* and sets
CSC_IDENTITY_AUTO_DISCOVERY=false, the switch the desktop rebuild command uses.
The wording now says what is ignored.
- The Store bundle step needs the claim timestamp, and the script deletes the
claim. The ps1 prints it as HERMES_RELEASE_EPOCH, and BUILDING.md documents it.
scripts/build-bundle.sh (macOS, Linux) and scripts/build-bundle.ps1 (Windows)
build the checked-out commit through scripts/bundles/desktop.py --commit. That
path needs no tag and no push.
Both scripts refuse a dirty tree before any work starts. Both delete the
previous build output first. The staged Windows payload is read-only, so a
plain delete in a reused checkout fails with WinError 5.
build-bundle.ps1 -Store builds the Microsoft Store MSIX. The driver admits
Store packaging only for a stable tag. The script makes a local claim tag
rc.<N>-vX.Y.Z for the next patch after the newest published stable tag. It
counts local and remote attempt refs and starts at 900, so it cannot collide
with a real attempt. A finally block deletes the tag. The script never pushes it.
The payload pinned libc++ 29 while the build container's apt cmake needs 30,
so the two disagreed and the shim in the previous commit papered over it. The
pool has since retired 17 of our pins, so repin them all with
`hermes pm update --termux`, which fixes the mismatch at the source.
Repinned: libc++ 29 -> 30 plus 14 more runtime libs (fribidi, glib, harfbuzz,
libaom, libbluray, libexpat, libheif, libmp3lame, libpng, libvidstab, openssl,
pcre2, readline, vulkan-loader-generic) and the bionic lock rows for uv
0.12.15 -> 0.12.23 and ffmpeg 8.1.2-5 -> 8.1.3. The pool serves ffmpeg 8.1.3
only, so that row moves back from 9.0.1 as the pool has it.
Staging with the new table succeeds: 84 packages, 269 .so, same counts as
before. The libc++ SONAME is unchanged. The one unresolved DT_NEEDED
(libharfbuzz-cairo -> libcairo) is present on the old table too.
The shim is removed. What stays from it is the in-container
`cmake -E capabilities` check, which fails in 2 minutes if the table drifts
from the pool again, instead of after 30 minutes of compiling.
The wheelhouse build stage still compiled cmake from PyPI source (pillow ->
pybind11 -> cmake bootstrap, exit 11) after the Dockerfile and the apt install
began upgrading libc++ to 30. LD_LIBRARY_PATH put the payload's pinned libc++ 29
first, and a library found there beats the binary's own RUNPATH, so apt's cmake
died with 'cannot locate symbol _ZNSt6__ndk113__hash_memoryEPKvm' and
scikit-build-core read that as 'no cmake'.
Probed in the pinned termux-docker image under qemu with the same apt package
list: payload libc++ 29 first fails, a one-symlink dir to the system libc++ in
front of it starts cmake and clang. The build now makes that dir after apt runs
and fails fast if cmake cannot start, instead of burning 30 minutes compiling
cpython, cryptography and pillow first.