Skip to content

Release 1.2.0 - #112

Open
GianniCarlo wants to merge 74 commits into
mainfrom
develop
Open

GianniCarlo wants to merge 74 commits into
mainfrom
develop

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Phone

Improvements

– Improved flow to connect Jellyfin and Audiobookshelf servers
– Quick Connect sign-in for Jellyfin
– Single sign-on (SSO) for Audiobookshelf
– Next/Previous and Android Auto follow your library sort

Bugfixes

– Out of storage now pauses playback instead of crashing
– Fixed crashes with huge cover art and deleted books
– Volume Boost no longer freezes the app
– Media-server imports keep the real file type

If you experience any issues, please reach us at support@bookplayer.app

Wear

Improvements

– Your library on the watch follows the sort order you chose on the phone

Bugfixes

– Running out of storage pauses playback instead of crashing
– Fixed rare crashes when starting playback or after deleting a book

If you experience any issues, please reach us at support@bookplayer.app

Two surfaces ignored the sticky sort and walked raw orderRank:

- getAdjacentItem fed the player's next/previous buttons and end-of-book
  auto-advance from rank-ordered siblings, so under an automatic sort the
  player jumped to a different book than the visible neighbor — on the
  phone, Auto, and Wear alike. The repository now carries a
  property-wired effectiveSortResolver (the manager depends on the
  repository, so a constructor arg would be a cycle; null keeps rank
  order for targets without sort prefs) and orders the playable siblings
  by the rule before indexing. The auto-advance path also stops
  constructing a bare inline RoomLibraryRepository and goes through the
  shared getRepository instance, which playNext/playPrevious already use.

- Android Auto's browse tree listed the Library tab and folder
  drill-downs in rank order. onGetChildren now applies the location's
  effective sort via the previously-unused LibrarySortManager
  .sortedForDisplay one-shot, before pagination so page slices stay
  stable. Recent and in-car search keep recency order by design. A new
  observeSortPreferences flow (filtered store snapshot) drives coarse
  cache invalidation: any sort change refreshes the Library node,
  mirroring the existing Recent-tab notifyChildrenChanged pattern.

BOUND volumes are untouched (they resolve Unresolved → Custom → rank,
which is the playback timeline). AdjacentItemSortTest pins the fallback
(no resolver ⇒ rank), the rule walks for title and most-recent, Custom
staying rank-ordered, per-folder independence, folder exclusion from the
walk, and sortedForDisplay's automatic/Custom split.
The watch rendered every library level in raw orderRank because nothing
on wear constructed the sort machinery: no LibrarySortManager, no
preference sync processors, an empty DataStore. A folder sorted by Title
on the phone listed in custom/import order on the watch.

Pull-only preference sync, mirroring the iOS watch:

- WearSyncServiceHost registers PreferenceFetchProcessor (the active
  path — it writes pulled values into the watch's own DataStore) and
  PreferenceUploadProcessor (processor-set parity with the phone, so a
  preference task can never sit unhandled). StandaloneViewModel enqueues
  the preferences fetch alongside its contents fetch on open/refresh;
  the factory debounces to one pull per 30s per launch.
- WearApp builds a LibrarySortManager over the watch's repositories and
  wires the repository's effectiveSortResolver, so end-of-book
  auto-advance on the watch follows the visible order too.
- StandaloneViewModel applies the same view transform as the phone's
  list: items combined with the level's effective sort, rule-ordered
  when automatic, rank-ordered under Custom (and when no manager is
  wired — tests). The per-path effective-sort glue is hoisted from
  LibraryViewModel into LibrarySortManager.observeEffectiveSort(path)
  so phone and wear share one implementation.

Free accounts never pull (the fetch requires the sync engine, which only
runs for PRO — the standalone library is PRO-gated anyway) and nothing
on the watch writes sort preferences.
Make next/previous and Android Auto follow the visible library sort
Bring the Wear standalone library up to sort parity
…e embedded covers

Import made two allocations the size of the embedded cover, one line apart in
ImportManager.createBookItem: AudioChapterExtractor read the whole `moov` to find the
chapter track, and ArtworkManager read the picture whole via
MediaMetadataRetriever.embeddedPicture. A 29 MB cover against 10-13 MB of heap headroom
is Sentry ANDROID-BOOKPLAYER-17/-18.

The MP4 box and ID3 frame walkers now return byte ranges (ContainerReaders.kt); the
extractor materializes only the chapter trak, EmbeddedCoverLocator finds covr/APIC without
reading it, and ArtworkManager decodes the cover through a stream with inSampleSize.
CoverArtResolver routes through the same path. Verified on an API 31 emulator at a 64 MB
heap growth limit: both former OOM sites now import a 60 MB-cover m4b with chapters and artwork.
…ash-looping

A zero-filled or truncated preferences proto (typically a disk-full or interrupted write)
threw CorruptionException on every launch until reinstall (Sentry ANDROID-BOOKPLAYER-13).
ReplaceFileCorruptionHandler resets to defaults and logs a warning; losing playback
settings is the lesser harm.
…ro.md)

Scripts that reproduce production crashes on a low-end API 31 AVD and verify fixes
before/after: AVD creation, heap growth limit control (with the sys.boot_completed reset
that stop/start needs), DataStore corruption, huge-cover and huge-moov m4b generators, and
an import driver that walks Open-with -> Accept -> Library through the accessibility tree
and reports rows and artwork.
An oversized remote cover (over the 8 MB browse cap) is reported as EmbeddedArtwork.Failed,
not None: the picture exists and was only skipped, and CoverArtResolver negative-caches None
as "artless" for the process, which would have hidden the cover even after the book was
downloaded. MockWebServer tests cover the remote locate+save path and the oversized skip.
The streaming decode path only caught Exception, so an OutOfMemoryError from
BitmapFactory.decodeStream / createScaledBitmap (an extreme aspect ratio keeps inSampleSize at 1)
could escape saveEmbeddedArtwork and crash the import this branch hardens. decodeAndSave now
reports a tri-state: OOM maps to EmbeddedArtwork.Failed (transient, never negative-cached as
"no art"), an undecodable picture to None.
Import survives huge embedded covers; DataStore corruption recovery; emulator crash-repro rig
chapters.bookUuid and playback_sessions.bookUuid are FOREIGN KEYs to library_items. Both
writers run asynchronously after a play/load, so the book can have been deleted or replaced by
a sync pull by the time they land; the failed insert escaped a coroutine with no handler and
took the process down (Sentry ANDROID-BOOKPLAYER-15 via replaceChaptersForBook, -19 via
StatisticsDao.insertSession).

Both DAO writes now check the parent row inside the same transaction and write nothing when it
is gone. Statistics coroutines also run under a CoroutineExceptionHandler: bookkeeping must never
take playback down, whatever the database throws.
… the playback service

media3 keeps session ids in a process-wide registry and refuses a duplicate. The service used the
default "" id, so one session that was registered but never released in the same process made
every later service creation die with "Session ID must be unique" at
MediaLibrarySession.Builder.build() (Sentry ANDROID-BOOKPLAYER-1A; both reports are OPPO devices,
consistent with the OEM retrying service creation without killing the process). Each instance now
takes a fresh bookplayer-<n> id (controllers connect through the ComponentName, never the id), and
onDestroy releases the session even if the player's release throws.
…-session-id

Skip child-row writes for vanished books (-19/-15); unique media session id (-1A)
…udioManager

1.11.0 fixes the out-of-bounds timeline merge in MediaUtils.mergePlayerInfo — the top open crash
on 1.1.2 (Sentry ANDROID-BOOKPLAYER-Q, an IllegalStateException in PlayerInfo.Builder.build on
the app's own MediaController). BookTimelinePlayer derives every index from
BoundTimeline.chapterLocalOf, whose clamping BoundTimelineTest pins, so the race was upstream.

Consequences of the bump handled here:
- MediaNotification.Provider gained a required getNotificationChannelInfo(); the Wear
  OngoingMediaNotificationProvider delegates it to the default provider.
- 1.10 stopped honouring device-volume commands from a MediaController for local playback, which
  was the watch crown's path. DeviceVolume now adjusts STREAM_MUSIC through AudioManager and
  observes the platform volume broadcast only while a UI collects the fraction; ExoPlayer's
  setDeviceVolumeControlEnabled goes away with it.
media3 1.11.0 (fixes the mergePlayerInfo crash, -Q); watch crown volume via AudioManager
… full

Sentry ANDROID-BOOKPLAYER-12 / -10 / -1D / -1G / -S / -V are one condition: the device
has no free space, and the first write to fail took the process down (at zero bytes,
that was the database open at launch — SQLITE_IOERR_SHMSIZE on PRAGMA journal_mode).

StorageMonitor is the process-wide storage state. It is fed two ways: measured (free
bytes on the data volume; below 32 MB is critical) and observed (a write that failed for
lack of space — SQLiteFullException, SQLITE_IOERR_SHMSIZE, ENOSPC anywhere in the cause
chain — flips critical and stays sticky until a later measurement sees 64 MB again).
Its exceptionHandler records full-disk failures and forwards everything else to the
default uncaught handler, so real bugs still crash and still reach Sentry.

What the state drives in :core:
- PlaybackManager refuses to start while critical and pauses running playback the moment
  a progress write fails (an audiobook player that cannot save your place must not
  pretend to). This covers every surface — app, notification, Auto, Wear, Bluetooth —
  because it sits in the controller listener. Recovery clears the block; it does not
  auto-resume.
- StoragePolicy (pure, tested) runs no sync task while critical — every task ends in a
  database write — and holds file downloads while a transfer is known not to fit.
- DownloadFileProcessor pre-flights Content-Length against a 64 MB reserve instead of
  filling the disk and taking the database down with it.
- The long-lived scopes that write (PlaybackManager, TaskConcurrencyManager,
  SubscriptionManager, SleepTimerManager) carry the handler.

CoreContext.appContextOrNull lets the handler measure free space without forcing init
order.
… full

The phone and Wear surfaces for StorageMonitor:

- Launch gate: MainActivity shows a storage screen (free space, "Free up space" → the
  system storage UI, "Check again") instead of the app when the volume is critical at
  launch, so nothing touches the database. It re-checks on resume and restarts the app
  once space is back. Intent handling (shortcuts, "open with") is skipped while gated.
- MainScreen shows a banner while critical or while a transfer is waiting, re-measures
  every 10 s in that state, and shows the playback-blocked dialog.
- BookPlayerApplication measures right after CoreContext.init, starts the sync host only
  when not critical, and restarts it when storage recovers. The Sentry account binding
  moves onto the supervised app scope: its handler-less IO scope opened the database and
  was the second exit at zero bytes.
- ImportManager pre-flights each URI's length against the reserve (skipped, not copied)
  and reports a mid-copy ENOSPC instead of dying on it.
- Every remaining long-lived scope that writes carries the handler: ThemeManager, the
  Wear publishers, the widget, shortcuts, the sync host, WearApp, the tile.

Eight new strings; translations are handled separately.
Nothing referenced it — only the Gradle line — but its auto-initializer wrote to its own
database at process start, which made it the very first thing to die with no free space
(SQLiteFullException on WM.task-1 on the emulator). Re-add it with on-demand
initialization when the WorkManager sync migration lands.
startForegroundService() gives the service a few seconds to call startForeground(),
measured from the request, and onCreate runs on the main thread. The host used to open
the database and build its 17 processors first, so the promotion waited on all of that
plus whatever the main thread was already doing when the start landed (a recreate, an
import sheet — which is what the storage-recovery restart does). Sentry
ANDROID-BOOKPLAYER-1H / -X is that deadline expiring.

Promotion is now the first statement after channel creation, and a dataSync-budget
refusal short-circuits before any of that work. Hardening: the window is now just the
main-thread queue delay. (An emulator run appeared to reproduce -1H on the recovery
path; it turned out to be the guest's storage freezing for two minutes after a 4.6 GB
fill file was freed — see fill-disk.sh — so this is not a verified fix for -1H.)
fill-disk.sh fills the emulator's data volume to a given root-side figure (or to zero)
and frees it again. The header records what cost time: `df` as root counts ~140 MB of
root-reserved blocks the app's StatFs does not, removing the WAL to force the shm-size
failure discards unflushed rows, and at zero bytes the crash reporter itself cannot store
reports. The docs section records the launch / mid-playback / import scenarios with
before-and-after results, plus the recipes for -19/-15, -1A and -Q from the earlier fixes.
openStorageSettings falls back to Settings.ACTION_SETTINGS, which every device resolves, so "Free up space" is never a silent no-op.
fix: storage-full guard — launch gate, playback block, transfer holds
Sentry ANDROID-BOOKPLAYER-11: a Background ANR with the main thread parked in
LoudnessEnhancer.<init> → AudioFlinger::createEffect. Creating or releasing an audio
effect is a synchronous binder call into audioserver, and we made it from ExoPlayer's
onAudioSessionIdChanged listener, which runs on the application looper. On a low-end
Redmi (Android 14) that call stalled long enough for the system to kill the process.

LoudnessBooster owns the effect on its own single-thread executor. The service only
posts attach / setEnabled / release commands, so a stalled audioserver stalls that
thread and nothing else. Commands apply in order: "attach to B" after "attach to A"
ends with B live and A released, the boost setting is remembered across re-attach,
and release drops later commands. The LoudnessEnhancer surface it uses is an
interface, so the tests drive a fake: a factory that blocks on a latch must not block
attach(), plus ordering, an unavailable-effect failure, and release semantics.

Not reproducible on the emulator (its audioserver never stalls); the contract is
what the tests pin down.
Emulator crash reproductions and chaos runs were landing in the production issue
list as fresh fingerprints (environment:dev on the 1.1.3+20 release; -1N, -1K, -1M,
-1J were archived for that reason, and -1H's count was inflated). The dev flavor now
compiles SENTRY_REPORTING=false unless local.properties has SENTRY_DEV_REPORTING=true;
prod is always on. Documented in CLAUDE.md and docs/crash-repro.md.
…h is attributable

Sentry ANDROID-BOOKPLAYER-1E ("Bad notification for startForeground", one TECNO on
Android 12) carries no cause on Android 12+ and its stack has no app frames, so the
report cannot say which service was promoting. Both sites now leave an `fgs`
breadcrumb: TaskConcurrencyServiceHost before startForeground (and on a denied
promotion), and AudioPlayerService.onUpdateNotification when media3 is about to
promote with the media notification. Nothing to fix until it recurs with that
context attached.
fix: LoudnessEnhancer off the main thread, dev builds out of Sentry, promotion breadcrumbs
… builds

The Play Console's R8 configuration panel flagged both as missing (Full Mode and
Resource Shrinking were already on). Neither needs the AGP 9 upgrade the panel also
suggests: both are one-line changes on AGP 8.13.

- android.r8.optimizedResourceShrinking=true: R8 shrinks code and resources as one
  reference graph, so resources referenced only from unused code go too (AGP 8.12+,
  the default from 9.0).
- -repackageclasses in the app and wear rules: obfuscated classes move to the unnamed
  package, dropping package-name strings from the DEX (the AGP 9.1 default). Kept
  classes are untouched, so the cross-process keeps (wear ongoing activity) hold.

Measured on unsigned prodRelease: phone APK 16.44 → 16.28 MB (67 of 321 resource files
removed, 8278 of 11149 classes repackaged); watch 8.79 → 8.43 MB (72 of 443 resource
files, 6668 of 9049 classes). Mapping identity of every by-name surface is unchanged.
…es its name

Play rejected wear 100007 (1.1.2, the first minified build) for "missing ongoing
activity": SystemUI deserializes OngoingActivity state by class name in its own
process, and R8 had renamed that machinery. Nothing in the build, the tests or CI could
see it; only Play review did. The keeps fixed it, but the only guard since has been a
manual mapping.txt grep.

scripts/audit-mapping.sh reads the prodRelease mapping and fails when any by-name
class was renamed or moved: every manifest component of the module (and the class an
activity-alias targets), Room's AppDatabase/_Impl, and on wear everything under
androidx.wear.ongoing and androidx.versionedparcelable. R8 synthetics are skipped.
CI runs it after the minified prodRelease assemble; the release workflow runs it right
after the bundle build, before anything is attached or uploaded. Verified: passes on
develop's baseline and on the repackaged build; fails with the wear keeps removed.
…ension

fix: never guess a media-server item's file extension on virtual import
} catch (e: SessionExpiredException) {
_sessionExpiredServerName.value = currentServer.name
return null
} catch (e: Exception) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — The broad catch (e: Exception) in prepareStreamImport also catches CancellationException, so if the enclosing coroutine is cancelled while getFileExtensions is in flight (e.g. the user leaves the screen), cancellation is swallowed and surfaced as a generic error instead of propagating. The network services in this same PR carefully rethrow it (catch (e: CancellationException) { throw e }). Consider adding that rethrow here (and at the pre-existing sites on lines 104/164) for consistency. Low impact.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Release 1.2.0 (develop→main): a large PR (~290 files) dominated by the :core module extraction plus four user-facing features — Jellyfin Quick Connect / AudiobookShelf SSO connection flow, sort-order-aware next/prev + Android Auto + Wear browsing, out-of-storage playback gating (replacing crashes), and hardened cover-art/chapter extraction (OOM-safe byte-range reads). Security-sensitive areas were checked closely: no secrets are hardcoded (all via BuildConfig), the OIDC/Quick Connect flow never logs the auth code, PKCE verifier, tokens or connect.sid, SSO is refused over cleartext, TLS is not weakened, and the Room v9→10 migration is present and tested. Media3 lifecycle (LoudnessEnhancer release off-main, session released in finally), coroutine scoping, StateFlow encapsulation, :core:app boundaries, localization and accessibility all hold up. Only minor, low-risk advisory items remain; nothing blocks merge.

Findings: 4 info

Model claude-opus-4-8 · run log · 3 new · 1 carried over · 0 resolved · advisory (a human should still review). Duplicate findings are de-duplicated and stale ones auto-resolved across pushes.

The README read like internal engineering notes ("Multi-Queue Engine",
"Tiered Access Policy") and undersold the app — no mention of Android Auto,
Wear OS, the widget, chapters, bookmarks, the sleep timer, Hardcover, media
servers or the 11 supported languages.

Rewritten to the iOS README's shape and voice (Import / Manage / Listen /
BookPlayer Pro / Roadmap / Locales, then Contributing / Maintainers /
Contributors / Community, then Dependencies / License / Legal), with the
feature list checked against the code. Adds the shared header banner, the
Play Store badge, and the current five-panel screenshot strip (1776px wide,
quantized: 4.2 MB source -> 193 KB). Community points bug reports at the
#bugs-and-feedback Discord forum channel, matching iOS.

Also drops library.png — an unreferenced duplicate of the empty-library
placeholder sitting at the repo root.

Separately, the Claude reviewer now skips fork PRs. Runs from a fork get no
repo secrets and a read-only token, so it would post a red X on every outside
contributor's first PR. Same guard the iOS repo already carries.
docs: rewrite the README for the open-source launch
_sessionExpiredServerName.value = currentServer.name
return null
} catch (e: Exception) {
_error.value = e.message?.let { UiText.DynamicString(it) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — On failure the raw e.message from the service (e.g. "Jellyfin API error fetching items: 500 ...") is surfaced to the user verbatim as UiText.DynamicString, an untranslated developer string. It carries only HTTP status/reason (no server body, so no HTML/JSON leak), but consider routing non-session failures through the existing R.string.media_servers_error_failed_to_fetch_library instead of the raw message for a localized UX.

* by a stale reading.
*/
private fun blockedByStorage(): Boolean {
val critical = appContext?.let { StorageMonitor.refresh(it).isCritical } ?: StorageMonitor.isCritical

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFOblockedByStorage() calls StorageMonitor.refresh() (a synchronous StatFs(...).availableBytes syscall inside synchronized(this)) from play()/load() on the main/transport thread, tripping StrictMode disk-read on every play. It's a single sub-ms stat by design (re-measure to avoid a stale reading), so impact is minor; if you want to keep the UI thread clean, gate on the already-observed StorageMonitor.state.value and kick refresh() to Dispatchers.IO.

null
}
if (located != null) {
return decodeAndSave(destFile) { ByteRangeInputStream(FileByteSource(audioFile), located.start, located.length) }.toEmbeddedArtwork()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — When EmbeddedCoverLocator.locate returns a range but decodeAndSave yields UNDECODABLE, the result maps to EmbeddedArtwork.None ("definitive, no art") and does not fall back to MediaMetadataRetriever the way the null-locate branch does — so a mis-located-but-actually-present cover could silently lose art the old platform path would have found. Well-mitigated by EmbeddedCoverLocatorTest (located ranges start with real JPEG/PNG signatures), so real-world triggering is unlikely. Optional hardening: map UNDECODABLE from a located range to Failed (retry via the platform reader) rather than None.

GianniCarlo and others added 7 commits September 11, 2026 09:32
…pass, state record, 233 tests

Replaces the first-generation Claude review harness with the hardened one. The agent
runs under `permissionMode: 'default'` behind a `canUseTool` gate whose Bash is a
grammar, not a shell emulator: it accepts only what it can prove it has parsed as
bash would, with commands and their flags allowlisted in full spelling because
getopt_long accepts any unambiguous prefix. Nothing that follows a symlink, never
returns, reads stdin, or takes filenames from a file gets through. `settingSources`
is empty, the agent's environment is built by allowlist, and the two tokens that can
write to the pull request are deleted from this process for the duration of every
agent call — so the isolation does not depend on how the SDK spawns.

Identity is stated rather than inferred. The prompt lists the findings still open
from earlier pushes and the agent may answer `same_as`; the fallback fingerprint is
corroborated against what the thread actually says, and a finding matched to a
thread that does not carry its text gets that text posted, so no identity decision
can bury a finding's wording. Nothing closes a thread except a judgement: a second
pass reads each still-open finding against the current code and answers fixed,
present, not applicable, accepted (by a maintainer — never the author), insufficient
or duplicate. Absence closes nothing. A close whose reason cannot be posted on the
thread is undone, because a thread left resolved with no marker and no record is
read next round as a maintainer's own resolve.

The harness writes down what it did. A hidden state record in the summary comment
carries which thread holds which finding and what was closed and why; the next round
reads it instead of doing archaeology over its own comments, and the record survives
a failed read, a truncated listing, an edited body, and a round that could not write
its summary — which now fails loudly instead of exiting green with nothing on the PR.

Every clock is bounded and the bounds are tested against each other: the review and
verification passes, the write phase's network retries, the GitHub client's ladders
and page loops, and the workflow's step and job caps, which `test/workflow.test.mjs`
computes from the harness's own constants so the numbers move together or the build
fails. A conservation law (`test/conservation.test.mjs`) fuzzes rounds against a
GitHub whose state drifts, lies about `same_as`, and refuses each kind of write in
turn, and states the one rule everything else serves: a reported finding is on the
pull request, or the round failed visibly.

Everything the model writes is untrusted at the write boundary and redacted on the
way to the log; a result-shaped example quoted inside a finding cannot be adopted as
the round's answer. The SDK is pinned exactly, because the sandbox rests on option
names that no stubbed test can see change.

This commit is the portable half only. `review-guide.md` is the one file that knows
which repository it is in; `review.mjs` no longer carries a second copy of that. The
Android CI gating that grew alongside this work on the previous branch is deliberately
not here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
github.mjs's two retry warnings quoted a thrown error's message outside the
redact() boundary review.mjs declares for every string that leaves the
process. The client cannot import redact (cycle), so it is injected:
setLogRedactor, installed by review.mjs at module scope, failing closed
(message withheld, name kept) until it is.

The test that enforces the boundary read only review.mjs and matched only a
plain `${x.message}`, so `${e.name || e.message}` was invisible to it. It now
reads both files, every `${...}` to its own closing brace, across multi-line
console calls, and requires the whole expression to be redact(...)'s argument.
Two seam tests pin the fail-closed default and the install; five mutations
killed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A finding the model reports with no usable file/line/comment/severity was
  discarded with a run-log line only; the summary now carries the count, on
  all three summary paths (dry run, no thread listing, full round). The one
  way a reported finding could leave the PR without a trace is closed.
- closeWithReason returned `why` and nobody read it; dropped, and the header
  bullet that still described the pre-round-29 behaviour (close stands, row
  carries the reason) rewritten to what the code does (close undone).
- The `Previously raised` label deliberately pairs the thread's live path with
  its live line; said so where the record's path is preferred for keying.
- conservation fuzzer: `filter(...) || []` was unreachable; removed.

Tests: discarded-count wording unit test + the malformed-findings round
asserts the summary line; three mutations killed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The node_modules ignore moves from the repository root into the harness
  directory, so "copy this directory and the workflow" copies it too; the
  root .gitignore is back to develop's. A workflow test pins it.
- The review prompt's open-findings list rendered an outdated thread's line
  bare while the verifier's prompt labelled it stale; both now use one
  STALE_ANCHOR_ATTR, and openFindings carries `stale` through.
- A test passed actionByFp an option it does not take (`unpostable` for
  `unpostableFps`). Fixed, and the class is now checked: for every exported
  function whose first parameter is an options object, a call spelling its
  options as a literal may only use declared names. The check found 14 more
  — every planRound({ provisional }) in the suite, a dead option since the
  verification pass took over closing — cleaned up.

Four mutations killed (stale dropped, stale rendered bare, module .gitignore
deleted, misnamed option restored).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
review.mjs (3,053 lines, four programs in one file) becomes one module per
seam, moved statement-for-statement with an AST tool, no logic rewritten:

  sandbox.mjs   tool gate, path rules, agentEnv, withheld tokens, redact
  repo.mjs      PER REPOSITORY: secret file names, secret shapes  (finding)
  identity.mjs  fingerprints, same_as, state record, markers, planRound
  prompts.mjs   system + user prompt (loads review-guide.md)
  agent.mjs     SDK options, run loop, model resolution, result parsers
  verify.mjs    verification pass, the only closer
  summary.mjs   sticky summary, record, size budget, notes, upsert
  config.mjs    env read at call time
  review.mjs    runReview: budgets and the order of operations (620 lines)

Shared modules read env per call (config getters, diffPath()/agentCwd(),
maxTurns()/maxOutputTokens(), captureSecretValues() at run start,
setModel()) so the tests' per-scenario environments still hold. The text
checkers (comments, option names, log redaction, budget prose) read every
module instead of two named files.

Round-4 findings, all agnostic:
- repo.mjs: the repository's secret files and shapes leave the portable
  half; sandbox builds REPO_SECRET_PATH and redact's tail from them.
- agent.mjs: the tool gate is also a PreToolUse hook (deny-only), so the
  path rules hold whether or not the SDK routes a Read to canUseTool.
- smoke.mjs: the install check loads the SDK AND runs the native CLI
  binary for this runner (execute bit + --version), replacing a check that
  proved only that JavaScript installed.
- README: layout table, porting = copy + edit two per-repository files,
  the 422-after-a-write-that-landed residual; package.json no longer names
  the repository.

241 tests; five mutations killed (hook absent, hook never denies, shapes
skipped, path rule hardcoded, smoke step dropped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…; address review feedback (round 5)

The workflow moves to pull_request_target, so the base branch's copy of it
runs. The review job checks the harness out from the base branch into
harness/ and executes only that; the pull request's tree is a second
checkout beside it (REVIEW_CHECKOUT) that the agent reads and the path rules
confine it to. The secrets are scoped to a `reviewer` environment whose
branch policy (develop, main) is what keeps any other pull_request workflow
from reaching them — a repository setting, documented in the README along
with what this does not close (the agent subprocess still holds the key) and
the next step (OIDC to Bedrock / a GitHub App token).

The pull request's own harness tests run in a second job with no secret, no
environment and a read-only token, gated on the pull request touching the
harness. A pull request that changes the harness is reviewed by the harness
it changes from; merging promotes it.

test/workflow.test.mjs reads jobs separately now and pins the split: the
event, the environment, the base-ref checkout, that every node the review
job runs is harness/, that REVIEW_CHECKOUT points at pr/, and that the tests
job references no secret.

Round-5 findings:
- review.mjs: a finding element that is not an object (null, a string, an
  object whose comment is not a string) is discarded and counted instead of
  throwing past the parse's try/catch into a red check. The conservation
  fuzzer's agent now emits such elements (20% of rounds), so the class stays
  covered — the mutation that removes the guard fails both the round test
  and the law.
- summary.mjs: model text inside the summary's <details> block cannot close
  it (</details>, <summary> escaped; code spans left alone).
- smoke.mjs: a spawn that could not run at all reports run.error.
- sandbox.mjs: the agent-output dump cap is read per call, like every other
  knob in a shared module.

246 tests; eight mutations killed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
reviewer: port the hardened Claude review harness
GianniCarlo and others added 4 commits September 11, 2026 16:18
… suite names no repository

Porting the harness to bookplayer-support-pipeline (its PR #16) found two
tests in the shared suite that were still this repository's: the redaction
test asserted Sentry/RevenueCat/client-id strings by hand, and the read-tool
deny checks named local.properties, keystore.properties and
google-services.json. A copy of the suite failed on a repository without
them.

REPO_SECRET_SHAPES entries are objects now — pattern, replacement, and the
`example` that proves the shape plus a `keeps` look-alike that must pass —
and the suite runs both for every entry, so a shape cannot be listed without
working and cannot eat prose. The deny checks iterate REPO_SECRET_FILES
through Read, Grep and Glob, and the template copy of each name. The two
harness directories are byte-identical again apart from repo.mjs and the
README's local-run coordinates.

Mutations: an example the pattern cannot redact fails the suite; a name
added to REPO_SECRET_FILES is refused by every read tool with no test edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The shape test asserts that THIS entry's pattern redacts its example: the
  example is run through a fresh RegExp of the pattern and the boundary's
  answer must equal that, so a generic rule or a sibling shape catching it
  cannot stand in. Fields are validated (RegExp, global, non-empty strings);
  `example`/`keeps` accept a string or an array.
- The Sentry shape lists all three DSN forms the old assertions covered
  (modern ingest host, legacy without and with a secret).
- The deny loop exercises Grep's `glob` field, the one an agent sweeps for a
  file by name with.

Mutations: an example only a generic rule redacts fails the suite; dropping
`glob` from Grep's path fields fails it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its reason

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
reviewer: the per-repository shapes carry their own proof; the shared suite names no repository
GianniCarlo and others added 4 commits September 12, 2026 10:55
actions/checkout v5 -> 3d3c42e5 (v7.0.1) and actions/setup-node v4 ->
82076278 (v7.0.0): a mutable tag lets whoever holds it swap the code that
runs inside the job holding the secrets; a commit cannot move, and v7 runs
on the Node 24 action runtime instead of the deprecated Node 20 one.

test/workflow.test.mjs now fails on any unpinned `uses:` or a pin without
its version comment.

checkout v7 refuses a fork's head under pull_request_target unless
allow-unsafe-pr-checkout is set; both jobs already skip fork pull requests
at the job level, so the input stays unset and is documented as the second
guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The pin test accepts subdirectory actions (owner/repo/dir@sha), skips
  local `./` actions, and takes any `# v…` version comment, not only
  three-component ones.
- Its name and comment say what it covers: the reviewer workflow only. The
  harness is portable, so it does not assert on the repository's other
  workflows; and it is a shape check — nothing local verifies that the
  commit is the tag in the comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pin test also matches the bare `- uses:` step form, which has no
`name:` key; an unpinned action written that way was skipped silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ci: pin the reviewer workflow's actions to Node 24 commit SHAs
release.yml: check out full history and, after the bundle build, run
`sentry-cli releases set-commits --auto` so Sentry's GitHub integration can
resolve `Fixes ANDROID-BOOKPLAYER-<id>` commits in the release that actually
ships them (the gradle plugin already creates the release entry at build time).
Metadata only, so continue-on-error: it must never block a ship.

sentry-deploy.yml (new): on push to main, finalize the current release and
record a production deploy. main only moves after the store has published the
build, so this is the reliable 'published' marker the weekly crash-triage
routine reads next to the traffic gate. Idempotent; fails only when the
release entry is missing, i.e. main moved without a build behind it.
…UDE.md

Two Git rules. Crash-fix commits carry `Fixes ANDROID-BOOKPLAYER-<id>` in the
commit body (not only the PR description: merges keep just the subject), never
plain-Resolve an issue in the Sentry UI (1.0.0+14 stragglers reopen it within
hours), and the REST inRelease call is for backfilling trailer-less fixes only.
Merging into main records a Sentry production deploy, which is only a reliable
signal because the release PR merges after the store publishes, never before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant