Files
meshcore-analyzer/docs/release-notes/v3.12.0.md
T
efitenandClaude Opus 5 9eb3098867 fix(release): publish the release notes as the release body (#2076)
Three releases went out with an empty release body. Checked with `gh
release view --json body`:

| release | body | assets |
|---|---|---|
| v3.9.1 | 1206 bytes | 2 |
| v3.9.2 | 2768 bytes | 0 |
| v3.10.1 | **0** | 2 |
| v3.11.0 | **0** | 2 |
| v3.12.0 | **0** | 2 |

v3.9.1 and v3.9.2 were written by hand, which this repository then
stopped allowing because published releases are immutable. Nothing
replaced them, so the `docs/release-notes/` convention was never wired
to the release page and three releases shipped with a description of
nothing.

Reported by the fork operator, who went looking on the v3.12.0 page for
the list of fixed issues that older releases carried.

## The cause

`action-gh-release` in this workflow was given `files` and
`fail_on_unmatched_files` and nothing else. No `body`, no `body_path`,
no `generate_release_notes`.

## The change

A step resolves `docs/release-notes/${GITHUB_REF_NAME}.md` and passes it
as `body_path`, with `generate_release_notes: true` so GitHub's pull
request list lands underneath the hand-written notes. A missing notes
file is a warning rather than a failure: the release then still gets the
generated list, which is more than an empty body.

## Also in this PR

The v3.12.0 notes file gains the two sections it should have had:

- **Issues closed**, 13 of them. Collected from each pull request's
`closingIssuesReferences`, not from commit text, which is why 30 pull
requests yield 13 issues.
- **Contributors**, separating pull request authors (@efiten 22,
@liquidraver 3, @A13xB0 2, @n30nex 1, @dborup 1, @anieto 1) from commit
co-authors (@nullrouten, @anieto, SaarMesh-Bot, Openclaw) from issue
reporters (@efiten 5, @n30nex 4, @anieto 2, @liquidraver 1,
@damn-simple-scripts 1). It says outright that 22 of the 30 pull
requests are the interim maintainer's own, which is the shape of a
release cut while the owner is unreachable, not a healthy ratio.

v3.12.0's published body has already been set to exactly this content by
hand, so the release page and the file agree.

## Worth recording

`gh release edit <tag> --notes-file <f>` updates a published release
body even though `action-gh-release` cannot. The immutability that
permanently burns a tag name does not extend to the description, so a
thin release body is recoverable. That was not obvious from the existing
comments, which describe releases as immutable without qualifying which
parts.

## Verification, and its limit

`yaml.safe_load` parses the file, but that proves little: it silently
accepts duplicate mapping keys that GitHub rejects outright, which is
how a previous workflow edit passed a local check and produced no runs
at all. The real validator is this PR's own pipeline, and the change
cannot be exercised end to end until the next tag is pushed.

## Not done

No backfill for v3.10.1 or v3.11.0. Neither has a notes file, and
reconstructing them is the same retrospective work deliberately skipped
for the 3.11.0 changelog entry.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 18:45:50 +02:00

9.5 KiB

v3.12.0

29 commits since v3.11.0: 15 fixes, 5 test, 3 perf, 3 feat, 2 ci, 1 chore.

Read this before upgrading

The first start after this release runs ANALYZE on your database, and ingest stalls while it does.

SQLite's query planner had no statistics at all, because ANALYZE had never run against these databases and nothing in the code ever called it. Without sqlite_stat1 the planner guesses, and on the channel queries it guessed wrong: it drove from the plain idx_transmissions_payload_type instead of idx_tx_channel_hash, the partial index the schema already carries for exactly that filter (#2058, reported with measurements by @anieto).

The ingestor now builds those statistics, once, on the first start against a database that has none. Measured on a 9.4 GB database with 1,250,489 transmissions and 14,169,329 observations:

ANALYZE duration, cold page cache 3m43.9s
ingest during that time stalled, buffered, nothing dropped
rows written in the minute it finished 1027, against ~130 either side

It holds the single SQLite write connection throughout, so observations stop arriving and queue in the ingest buffer, then land in one burst when it completes. On the deployment measured, the ingest buffer absorbed all of it and the log reported 0 dropped. The log now warns before starting, so this is distinguishable from a hang.

This happens once per database, not once per restart: sqlite_stat1 is an ordinary table and stays in the file. Every later start costs one query.

A deploy already restarts the service, so the stall overlaps an interruption you were having anyway. It is still worth picking the moment on a busy instance.

To skip it entirely, set db.analysisLimit to a negative number. The statistics then never get built and the channel queries keep the old plan.

What it buys, measured on the same database, region-filtered GetChannels:

pages read
before 143,442
after 107,429

25% fewer pages, about 147 MB less per query at a 4 KB page. Wall time when the OS page cache is warm does not change (0.80s either way); the gain is on the cold path, which measured 56.7s on that file. The 3-4x improvement reported in #2058 was measured on a smaller database (464,667 transmissions) and did not reproduce at this size.

db.analysisLimit defaults to 10000 rather than the 400 SQLite's documentation suggests. 400 and 1000 were measured to leave the plan unchanged; 10000 produces the same plan as an unbounded ANALYZE while costing 2.0s warm against that one's 242.9s.

Features

  • CoreDrive RX region answers are stored (#2047) - node_declared_regions was read by region_keys.go and config.go but created by nothing, so the Scope Audit page described a source that never arrived: the client-topic switch handled packets and rf only and dropped /regions without a log line. The table is now created at boot, and the answer is kept with position, the repeater's own clock, and per-collector history bounded by age rather than one row per target. Opt in through clientRegions, separate from clientRxCoverage, so region answers do not require GPS-tagged reception uploads. Measured on the instance CoreDrive RX publishes to, 2026-08-18 to 2026-09-19: 1840 answers about 275 repeaters from 51 collectors, of which 138 repeaters have answers from more than one collector and 40 have collectors that disagree, which is the disagreement the Scope Audit exists to surface.
  • The running version is in the nav drawer (#2068) - read from /api/health, with commit and build time in the tooltip. A version-less or failing health endpoint leaves the plain label rather than "CoreScope undefined".
  • The IATA whitelist says when it drops a region (#2067) - one throttled warning per rejected region, so a misconfigured observerIATAWhitelist is visible instead of silent.

Reception and node health

  • Zero-hop adverts count as direct reception (#2064) - a node's own advert with an empty path is credited to the observer that heard it, without needing prefix resolution.
  • Heard By lists only observers that heard the node on air (#2057) - a relayed packet no longer implies its observer heard the originator directly.
  • The Heard By empty state stopped claiming the node is out of range (#2063)
    • no direct reception recorded is not the same as out of range.
  • The node-detail advert section is called Recent Adverts (#2042, #2071) - it never held anything else, because transmissions.from_pubkey is populated for adverts only. The heading now says so in its tooltip instead of promising every packet type.
  • Blacklist retention is queried by from_pubkey (#2069).

Data correctness

  • Each observation carries its own wire bytes (#1999, #2055) - the packet detail API was handing every observation the same canonical frame, so per-observer differences were invisible.
  • Background chunks merge in order (#2050) - they were prepended, which put history out of sequence.
  • Pagination continues past a page that post-LIMIT filtering shortened (#2061) - a filtered page that came back short ended the walk early.
  • maxMemoryMB eviction actually triggers (#2035) - path, decode-cache and dedup-key bytes were not accounted, so the measured footprint sat below the configured limit and eviction never fired. Instances relying on that limit will now evict where they previously grew.
  • The distance index shows a building state on its 202 (#1997, #2051) - the Analytics distance tab treated the lazy index's 202 as data and crashed, and the 202 was being cached, so the tab stayed broken until the cache expired.

Interface

  • Live legend toggle is clickable again (#2049) - the VCR bar was eating its clicks.
  • Map inspector controls stay clickable, stale loads are ignored (#2048).
  • RX coverage opens at the configured map default (#2033), with its own saved viewport.
  • Scope Audit observed-region chips meet 4.5:1 in light theme (#1996, #2070).

Performance

  • Concurrent GetChannels cache misses coalesce (#2059) - the second and later callers wait for the first flight instead of each running the query.
  • Planner statistics (#2058, #2072, #2074) - see the top of this file.

Tests, CI and release tooling

  • Every suite in tests/e2e runs (#2053, #2045) - eight suites had no runner at all, and the aggregate reported a pass while skipping them.
  • Each E2E suite is named in the log before it runs (#2046).
  • Root test-*.js moved into tests/unit and tests/e2e (#2036).
  • RX-coverage viewport assertions budgeted in pixels rather than a flat 0.001° (#2040).
  • The #1239 lock-hold threshold set from measurement, 150µs to 5ms (#2039) - the old value sat inside the healthy band and failed at random.
  • RF-sample fixtures anchored to now instead of a calendar date (#2034).
  • Ingestor test stores wait for boot migrations before being handed over (#2065, #2066) - this was a real data race under -race.
  • Re-tagging :edge as a release keeps every platform (#2031) - v3.11.0's tags were flattened to amd64 and had to be repaired.

Issues closed

13 issues, from the 30 pull requests in this range. Collected from each pull request's closing references rather than from commit text.

  • #1996 bug(scope-audit): observed region chips fail light-theme contrast
  • #1997 bug(analytics): Distance tab crashes on the initial 202 index-building response
  • #1998 bug(map): Map Controls intercepts the Path Inspector toggle on desktop
  • #1999 bug(packets): detail API discards distinct per-observation wire bytes
  • #2024 store: retention eviction assumes packets sorted by first_seen, but startup load breaks that order
  • #2029 GetChannels/GetEncryptedChannels: no request coalescing on cache-miss
  • #2032 feat: make rx-coverage menu use the center that config.json sets for Map
  • #2037 18 suites in tests/e2e are invoked by no runner, and one cannot run at all
  • #2038 TestComputeAnalyticsDistanceLockHoldDuration fails on runner noise: the 150µs limit is absolute
  • #2042 node detail "recent packets" only shows adverts, so the name misleads
  • #2056 Node detail "Heard By" lists observers that never heard the node, with signal numbers belonging to another node
  • #2058 GetChannels/GetEncryptedChannels: cold query cost is driven by payload_type, not region
  • #2065 Race detector: an async-migration goroutine outlives its test and races the log capture

Contributors

Pull request authors: @efiten (22), @liquidraver (3), @A13xB0 (2), @n30nex (1), @dborup (1), @anieto (1).

Also credited as co-authors in the commits: @nullrouten, @anieto, SaarMesh-Bot, Openclaw.

Issues in this release were reported by @efiten (5), @n30nex (4), @anieto (2), @liquidraver (1), @damn-simple-scripts (1).

22 of the 30 pull requests are the interim maintainer's own, which is the shape of a release cut while the owner is unreachable rather than a healthy ratio.

Upgrade notes

No manual migration step. #2047 adds the node_declared_regions table, created at boot with CREATE TABLE IF NOT EXISTS, and #2058 creates sqlite_stat1 the same way; nothing else touches the schema.

clientRegions is opt-in and off by default.

The ANALYZE described at the top needs no action, only awareness. Set db.analysisLimit to a negative number to disable it.

One behaviour change worth repeating here: #2035 makes maxMemoryMB eviction fire where it previously did not, because the footprint it compares against the limit was undercounted. An instance that set the limit and never saw eviction will start seeing it.