• v0.8.0-rc3
    TrueNAS compatibility / compat (push) Successful in 15s
    Release / release (push) Successful in 15s
    CI / python 3.11 (push) Failing after 7s
    CI / python 3.12 (push) Successful in 17s
    CI / python 3.13 (push) Successful in 20s
    CI / shell (shellcheck + syntax) (push) Successful in 9s
    Pre-Release

    flan released this 2026-08-26 01:20:27 -04:00 | 2 commits to main since this release

    Changed

    • CI's python matrix is green on the self-hosted Gitea runner again. The real
      failure was that the Gitea runner image executes jobs as root, and the two
      unreadable-sidecar tests build their scenario with chmod(0) — which cannot make
      a file unreadable for root (CAP_DAC_OVERRIDE). Those two tests now skip as root
      with that reason; GitHub's non-root runner still exercises them. The matrix also
      moved to uv-managed interpreters (one toolchain across both runners) and ruff is
      pinned to 0.16.1 so an upstream ruff release can't turn main red without a code
      change.

    • README badges point at the public GitHub mirror (workflow status and
      releases) instead of the private forge. The release badge had also been reading
      the stale Gitea v0.6.1 release instead of the current v0.7.0 on GitHub.

    • master is now labelled 27-dev, because it is not the next release. iX
      branches each major onto its own release/ line and master rolls straight on to the
      one after — on 2026-07-14 every recent commit on master targeted 27.0.0-BETA.1
      while 26 was still in beta. So a BROKEN master row, rendered as
      "master (unreleased)", read as "the version you are about to install is broken"
      when the breakage was a major release away on a line nobody can download. In a table
      whose entire job is helping somebody decide whether to trust this with their backups,
      that is a false alarm in the worst possible place. The label is derived from the
      newest major in the matrix plus one, so it rolls over to 28-dev by itself once 27
      branches.

      For the record, the breakage is NAS-141498 (2026-06-24), "Convert cloud_backup
      plugin to the typesafe pattern": it re-signatures restic_backup and
      get_restic_config, splitting entry/credentials out of the cloud_backup dict.
      It is deliberately not being chased while the 27 line is still churning.

    Fixed

    • The patch survived being applied and then silently stopped existing, because
      something else remounted /usr four seconds later.
      On a box running
      TrueNAS 25.10.6 the boot of 2026-08-19 went: 16:41:56 apply.sh mounts its
      overlay on /usr/lib/python3/dist-packages, patches b2.py/restic.py, logs
      every step OK; 16:42:00 a second PREINIT hook runs systemd-sysext refresh over /usr — Unmerged '/usr'. / Merged extensions into '/usr'. —
      and our overlay, which lives inside that hierarchy, is torn off with it;
      16:47:24 our own deferred restart fires exactly as designed and middlewared
      imports the stock modules. Every B2 TrueCloud Backup task then failed with
      NotImplementedError from stock rclone/base.py for nineteen hours, across
      four scheduled runs, while apply.log and hook_status.json both said the
      patch was active.

      Nothing in the patch was wrong, which is the point: applying at PREINIT and
      restarting later is only sound if the patched files are still on the live path
      when middlewared re-imports them, and that is not something PREINIT can
      guarantee
      . Init scripts run sequentially in id order, so any hook registered
      after ours always wins. Worse, hook ordering cannot fix it either —
      middlewared's own docker.configure_nvidia merges a sysext over /usr at
      runtime, long after every PREINIT hook is finished.

      So the deferred restart no longer trusts the PREINIT pass. wait_restart.sh
      now re-applies immediately before it restarts middlewared — after boot has
      settled, which is also after every sysext merge and docker nvidia
      configuration — and verifies the marker is genuinely on the live path before
      restarting. It is no longer exec systemctl try-restart middlewared, because
      something has to run afterwards.

      What runs afterwards deliberately does not restart again. try-restart
      returns as soon as middlewared is READY, and middlewared then brings docker up
      — docker.configure_nvidia merges the stock nvidia sysext over /usr at that
      point, detaching the overlay after the patched modules have already been
      imported. A disk check there reports "missing" on a perfectly healthy system,
      and restarting on that signal would restart a correctly-patched middlewared
      straight back into the same race. So the overlay is re-mounted for the benefit
      of the next restart, and the question of whether this middlewared actually
      holds the patch is left to the one thing that can answer it exactly — the
      in-process alert below. That re-mount preserves hook_status.json's
      patched_at: create_task.py verify decides "loaded" by comparing
      middlewared's start time against that stamp, so a re-apply running after the
      restart would have made the stamp newer than the process which correctly
      imported the patch, and verify would have reported FAIL forever on every
      boot where the sysext merge detaches the overlay. Caught on hardware while
      validating the candidate — a new lying status introduced by the fix for a
      lying status.

      Two supporting fixes fell out of the same failure. _ensure_writable treated
      "one of our overlays is listed on this directory" as "already done" — but it
      only ever reaches that check when the directory is not writable, and a live
      overlay of ours always is. A shadowed overlay was therefore indistinguishable
      from a healthy one; it is now detached and re-mounted, reusing the same
      upperdir so everything patched earlier in the boot reappears intact, with a
      fresh workdir because overlayfs refuses one left behind by a detached mount.

    • middlewared now says so when it is running stock. The gap that let this
      cost nineteen hours was not the remount, it was that nothing could tell the
      difference between "patched on disk" and "patched in the running process".
      apply.log can only ever report the first. A new CRITICAL alert asks the
      second question from inside middlewared, hourly, where it is exact: the patch
      stamps the objects it replaces, so a missing stamp means this interpreter
      imported stock code. It checks both halves — restic.py's _truecloud_patched
      marker and whether B2RcloneRemote.get_restic_config is still the base class's
      — since either can go missing alone. It stays quiet when the kill switch is
      set or the providers module has been retired as native, and it is deliberately
      not silenced by update_alerts_disabled: that mutes release notifications,
      not a broken backup path.

      Boot-time diagnosis also no longer depends on the journal. wait_restart.sh
      logged only to the journal, and journald retention on a busy box is easily
      shorter than the interval between reboots — the 2026-08-19 boot had already
      rotated away by the time it was investigated. It now writes to apply.log
      alongside everything else.

    • The next maintenance release was never checked, and it is the one that reaches
      users.
      Shipped versions were discovered from TS-* tags and unreleased ones from
      release/* branches carrying -BETA/-RC. A branched-but-untagged maintenance
      release is neither: release/25.10.5 has no tag, and its line has already shipped,
      so the "a prerelease of a shipped line is history" filter discarded it. It was
      invisible — and it is precisely what a 25.10.4 box gets on its next update. A break
      there would have reached real users before the daily check ever looked at it, on the
      only line anybody is actually running.

      A plain release/X.Y.Z branch is now checked when its line has shipped and it
      sorts newer than that line's newest tag. Both things that must stay out fall out
      of the same rule: release/24.10-RC.2 sorts older than TS-24.10.2.4 (history, not
      a warning), and iX's typo branch release/25.20.2.2 is on a line that has no tag at
      all, so it is not a release line. This immediately surfaced two refs that had never
      been checked — release/25.10.5 and release/24.10.2.5 — both of which pass.

      is_unreleased() now keys off where a ref came from (branch = not yet shipped)
      rather than looking for -BETA/-RC in its name. Otherwise release/25.10.5 would
      count as shipped and a break in it would fail the build as a live outage — on a
      version nobody is running yet.

    • An unchanged fingerprint froze the bug report's body, not just its comments. Two
      questions were sharing one answer. Have the findings changed? gates comments —
      they notify, and a daily "still broken, same as yesterday" is what teaches everyone
      to ignore the one that finally matters. Is the body still true? gates the body —
      and editing an issue body notifies nobody on either forge, so keeping it honest is
      free. Conflated, the report could never be corrected while the findings held steady,
      and the fingerprint deliberately ignores everything that moves on its own — healthy
      rows, the hardware-verified column, point releases, and how a row is labelled. The
      master → 27-dev relabel above would have reached the README and never the issue
      anybody actually opens. The body is now rewritten whenever it is out of date (after
      normalising line endings, so a forge round-tripping \r\n does not cause a rewrite
      every run) and comments remain strictly a changelog of real changes.

    • A change to the publisher did not re-run the check. compat.yml's push: paths
      listed tools/compat.py but not tools/compat_publish.py — so the very commit that
      taught the bot to refresh a stale report body triggered no run, and the report stayed
      stale until the next scheduled one. A fix nobody runs is a fix nobody has.

    • The compatibility bot filed a new duplicate bug report on every Gitea run.
      find_issue() skipped pull requests by testing for the presence of the
      pull_request key. GitHub omits that key on a plain issue; Gitea sends it as
      null. So on Gitea every issue was discarded as a PR, the lookup always came back
      empty, and the bot took the "nothing filed yet" branch and opened a fresh report
      each run — nine copies on the canonical forge, four of them filed after the
      commit that was meant to stop precisely this. The mirror was fine, which is why it
      went unnoticed: GitHub's payload shape is the one the filter was written against.

      It is the same failure the anti-spam fix was written to prevent, moved from
      comments to issues, and it survived because find_issue was the only function in
      compat_publish.py with no test. It now has one, per forge, and the daily cron —
      which had not yet run once — no longer accumulates a report a day.

      The issue list is also requested with both paging parameters (per_page for
      GitHub, limit for Gitea). Each forge ignores the other's, and Gitea's default page
      is 30, so the lookup would have started missing the report again once the pile it
      was creating grew past one page.

    Downloads