Skip to content

Fix/audit cycle 33987355439 - #29

Merged
Y-PLONI merged 7 commits into
otzariafrom
fix/audit-cycle-33987355439
Sep 9, 2026
Merged

Fix/audit cycle 33987355439#29
Y-PLONI merged 7 commits into
otzariafrom
fix/audit-cycle-33987355439

Conversation

@Y-PLONI

@Y-PLONI Y-PLONI commented Sep 8, 2026

Copy link
Copy Markdown
Member

No description provided.

Y-PLONI and others added 6 commits September 9, 2026 12:13
…ith a URL sidecar; one URL grammar; a build gate on failed embeds

Squashed from the audit branch by file set (3 original commits contributed; their messages follow).

--- f918165
fix(images): retry + durable cache for textimages, one URL grammar, and a build gate on failed embeds

Builds on the owner's in-progress work (downloadWithRetry with 3 attempts
and backoff, HTTP 418 treated as final, durable cache via -PimageCacheDir)
and closes what the v27 build logs showed:

- collectUrls stopped at whitespace while IMG_TAG_REGEX did not, so
  `…/ShaarHahakdamot/Screenshot 2023-… .png` was fetched truncated and could
  never be substituted even after a successful download. One shared URL_BODY
  now drives both; the literal URL stays the map/cache key and only the HTTP
  request is percent-encoded (existing `%20` is left alone).
- newlyDownloaded/failed were plain Ints incremented from 16 coroutines
  (`cached=410` vs `downloaded=409`); now AtomicInteger + ConcurrentHashMap.
- A failed download silently left the remote URL in the row (10/420 images
  shipped as network URLs in v27). The embedder now writes
  image-embed-report.json (counts + one entry per failed URL with reason:
  timeout / http <status> / content-filter 418 (NetFree on the self-hosted
  runner) / malformed-url / oversized), and the build step reads it:
  ::warning:: per failed URL, ::error:: and exit 1 above
  IMAGE_EMBED_MAX_FAILURES (default 20) or when the report is missing.
- Log volume: one summary line (greppable `cached=` prefix) and one
  stack-trace-free line per failure instead of a stack trace per attempt
  (81% of the embedder's log lines were repeated frames); cache hits are a
  count.

Tests: 5 new (spaced-URL round trip, request-only encoding, failure report
contents, exact counters under 120 concurrent URLs, report-path property);
:sefariasqlite:jvmTest --tests '*SefariaImageEmbedder*' 14/14.

--- 5888ffd
fix(images): content-addressed cache keys with a URL sidecar; migrate the legacy cache without a download

cacheFileName replaced `/`, `:` and `?` with `_`, so two distinct image
URLs could map to one durable cache file and the next build served the
wrong image from the cache and reported success. The key is now
sha256(url); each entry carries a `<sha>.url` sidecar written before the
bytes (temp + atomic move), and a hit requires the sidecar to name the
requested URL, otherwise the entry is evicted and fetched again.

The legacy layout is migrated in place: on a miss the old name is looked
up, claimed by writing the sidecar and renamed, so a warm cache costs
zero downloads (verified on the 417 URLs of the 2026-09-01 export: 417
served from disk, 0 fetched, 0 legacy collisions) and the
IMAGE_EMBED_MAX_FAILURES gate is never exposed. Legacy names that two
URLs of the same export would share are refused outright so a historical
collision is repaired rather than frozen. URLs whose legacy name is not a
valid path on the host are treated as a miss instead of throwing.

--- 35c8f42
fix(images): a cache hit requires a byte-exact URL sidecar; drop the provenance-free legacy migration

Round 3 of the audit of cycle 33987355439 (item S15).

The legacy cache name was the URL suffix with `/ : ?` folded to `_`, which is not
injective (…/Tikkunei_Zohar/40.png and …/Tikkunei_Zohar_40.png collide), and the
migration adopted such a blob under the new sha256(url) key with a "verified"
sidecar without any proof of which URL had produced it. The durable cache directory
was introduced by this very branch and no release run has executed on a branch head
(the single run on 15ef349 never started a job), so there are no legacy entries to
migrate: the migration, the dual read and the two-URLs-one-name refusal are removed.
A hit now requires `<sha>.url` whose bytes equal the URL exactly; anything else is a
miss and a fresh download, with the sidecar written before the bytes as before.

Tests: no hit without a matching sidecar, a byte-inexact sidecar is a miss, a
legacy-named blob (alone or as a collision pair) is never adopted, miss → download →
sidecar + blob. SefariaImageEmbedderTest 21/21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hase summaries; dropped data summarised and named; fail closed on seed, snapshot and buildstate; atomic candidate publish

Squashed from the audit branch by file set (4 original commits contributed; their messages follow).

--- ace5f88
fix(generator): one DEBUG line per book, a throttled progress line, and a phase summary instead of 17,800 INFO lines

Audit of cycle 33987355439 (item S4a, report 06-db-generator-step).

The "Generate Seforim Database" step logged 24,207 lines / 2.8 MB for
~1,500 books, 94.8% of it boilerplate: a 6-line INFO block per book, the
book header printed twice from two call sites, a line tick at
lineIndex == 0 for every book (`0/N (0%)`), and `Books progress: n/N`
after every single book.

Generator.kt (logging only; the serial book-insert loop, its order, id
allocation and every insert call are untouched — verified by diffing
the control-flow skeleton with all logger calls stripped):
- the per-book INFO block is one DEBUG line (id, title, file, category,
  lines, toc, acronyms, ms); processBookContent/processLinesWithTocEntries
  return BookContentStats; a logger.e + rethrow keeps the "which book
  blew up" breadcrumb for content failures.
- the three `Books progress` sites fold into one throttled INFO line
  every 100 books or 60 s: `books 1234/1501 (82.2%) · lines 5.2M ·
  toc 301.0K · elapsed 27:08 · eta ~05:52` (15 lines on the real run).
- line ticks need lineIndex > 0, a book above 5,000 lines and ≥10% or
  60 s since the last tick (3,239 -> 507 on the real book sizes; the old
  rule replayed over them reproduces exactly the 3,239 observed).
- the duplicated `📚 Processing book` site in processDirectory is gone;
  the createAndProcessBook site (which also serves the priority-list
  caller and carries the categoryId) stays.
- acronym inserts, `===` directory lines, `✅ Category` and the `📝`
  merge lines are DEBUG; their counts land in one summary per phase:
  `Otzaria book import (phase 1): books=…/… lines=… tocEntries=…
  directories=… skipped=… acronymBooks=… hearotMerged=… elapsed=mm:ss`
  (counters reset per phase — QA fix).
GeneratorProgress.kt: clock-injected cadence gates and locale-free
formatters; GeneratorProgressTest.kt: 18 tests on a fake clock.

DEBUG is off in CI (Logger.setMinSeverity(Info) on every CLI entry; the
audited log has 0 Debug lines), so the demotion is real. No consumer
parses any removed marker (repo, pipeline-monitor, otzaria-library,
LinkerToOtzaria checked); `build_state.db snapshot written`,
`Persisting in-memory DB`, SefariaDirect `Processed N/N books` and
`BUILD SUCCESSFUL` are byte-identical.

Measured on build-job-34024655297.log: -17,838 lines (-73.7%) and
-1.98 MB on the step. Left for S4b: the 1,286 `Target book not found` /
`Original path` lines (their fix is a WARN summary for the 643 dropped
manual links) and the insert-path failure breadcrumb.

Tests: :otzariasqlite:jvmTest --rerun 36/36.

--- f2079c5
fix(generator): summarise dropped data instead of spamming it; make the summaries agree; name what was silently skipped

Audit of cycle 33987355439 (item S4b, report 06-db-generator-step).
Logging, counting and report-file changes only: the serial book-insert
loop, id allocation, SQL and every row written are unchanged (verified
by a skeleton diff with all logger calls stripped, 19 hunks in
Generator.kt, each a counter / set insertion / report call / currentBook
assignment / log-and-rethrow / the _headings filter).

- 37 Talmud `<masechet>_headings` JSON files (heading -> line maps) were
  fed to the link parser: 74 parse attempts (each tried twice), a 40-line
  JSON dump per failure, then `Source book not found for links` x37.
  They are excluded from link-file discovery with one INFO line. Proven
  no-op for the data: parseLinksFromJson returned emptyList() for them,
  so zero rows and zero ids ever came from them.
- Priority list 430/431 missing (Generator.kt list never re-pinned after
  the directory restructure): 430 WARNs -> one WARN with counts, the
  first paths and a pointer; full list in the report file. The list is
  NOT re-pinned or deleted (changes insert order -> ids; operator
  decision).
- 642 x `Target book not found: עולת ראיה` + `Original path` -> one WARN
  per missing target with count and path (bounded), plus a total. Links
  are still dropped exactly as before (otzaria-library O7 fixes the data).
- Contradictory summaries: `type=REFERENCE resolvedPairs=4421
  written=106912` was a labelling problem — the read counters are keyed
  by the CSV's `Conection Type`, `written` by the stored type after blank
  inference. Labels are now csvRowsRead/csvDropped/csvResolvedPairs/
  storedWritten with a reconciling totals line (3,899,477 resolved vs
  3,886,967 written; gap 12,510 = heading/self-link filters + INSERT OR
  IGNORE). `GenerationApplyResult(…, 0)` hard-coded unmatched=0 next to
  "13 unmatched" — fixed. All-metadata's 1,116 unmatched titles are
  named (bounded WARN + report file) for otzaria-library's O6. The JSON
  metrics consumed by the QA scripts are byte-identical.
- build_state.db written 5x: all five writers traced to consumers (four
  feed the next stage's InMemoryIdAllocator.load, the fifth is the
  published seforim.db.buildstate.zst); none dropped; each write now
  prints its seconds.
- 407 books without a source hash: Sefaria's hash computer walks
  merged.json before the blacklist filter, so 395 blacklisted books
  contribute hashes no id claims; Otzaria's 16 are classified. Named per
  class; recording behaviour unchanged (operator decision).
- Havrouta 38 found / 37 processed: the unmatched tractate is named with
  the reason. line_ref's 90 ambiguous keys are named (bounded) and
  written to the report file.
- The insert-path failure breadcrumb removed by S4a is restored via a
  currentBook field reported by the outermost catch (no re-indent, no
  control-flow change). GenerateLinks.kt logged the literal
  `${tables.size}` — fixed.
- New common/reports/GeneratorReport.kt writes the bounded lists to
  <root>/build/generator-reports (pinned in root build.gradle.kts because
  each generator stage forks with its own subproject cwd). Nothing
  collects that directory yet: the workflow contract forbids Actions
  artifact storage (every handoff is a content-addressed pre-release),
  so wiring it is an operator decision.

Measured on build-job-34024655297.log: about -4,750 lines / -0.40 MB
(the 40-line exception blocks were the audit's "blank stack-frame"
lines), +~19 summary lines. Tests 495 -> 527 with the same 9
pre-existing Windows path failures; compiler warnings 8 -> 8; workflow
contract tests 42 -> 44 (the workflow itself is untouched).

--- f4d5c01
fix(generator): fail closed when the seed copy or the buildstate snapshot fails; verify the written state

GenerateLinks and GenerateLines wrapped the seed (ATTACH the base DB and
copy every table) in runCatching and continued on failure; in
appendOtzariaLinks the base DB and the persist target are the same file,
so a failed seed let the later VACUUM INTO replace the 7 GiB DB with an
empty one and the build reported success. Both seeds now log the base DB
and the table the copy died on and rethrow, before any VACUUM INTO; the
pre-existing target is left byte-identical.

The five snapshotTo sites (Sefaria, metadata seed, lines, links, havrouta)
swallowed a failed buildstate write with a warning, so a DB could ship
with an allocator state the next build would reuse to hand out ids twice.
All five now rethrow, and GenerateLinks snapshots after its persist so a
failed persist never advances the state. After every snapshot,
BuildStateVerifier reopens only the meta and id_counters tables, checks
the stamp is this run's, and asserts next_id > MAX(id) for each
allocator-issued table (one b-tree edge per table, ~20 ms on a 5 GB DB);
alt_toc_entry is excluded with the evidence that its ids are implicit
rowids by design, which real generator output confirmed.

--- 691b8a9
fix(generator): a missing required seed fails before the target is touched; publish through an atomic candidate

Round 3 of the audit of cycle 33987355439 (item S16).

GenerateLinks and GenerateLines warned and continued with an empty database when the
seed was absent while appendExistingDb was set, and the release tasks pass one path
as both base and target, so the reachable outcome was an empty DB shipped as a
success. Now: appendExistingDb with no seed throws before any write to the target,
naming the path and the opt-in that permits an empty base (-PallowEmptyBase,
forwarded by the four seed-using JavaExec tasks because a Gradle -P is not a system
property of the forked JVM). Both generators publish through DbPublish: the result is
written to <target>.candidate in the same directory, verified, and moved over the
target with ATOMIC_MOVE + REPLACE_EXISTING; stale -journal/-wal/-shm beside the
candidate and the target are removed around the move. The publish → snapshot →
BuildStateVerifier order from f4d5c01 is unchanged.

Tests: OtzariaBuildFailClosedTest 13/13 (throw before the target is modified, no
candidate left behind, stale WAL does not survive the publish, the release tasks
forward the opt-in and pass one path as base and target). otzariasqlite 57/57.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot silently skipped; QA drift is gated and observable

Squashed from the audit branch by file set (1 original commits contributed; their messages follow).

--- 9b586e0
fix(qa): a Sefaria schema that parses but is not a book is counted, not silently skipped

load_schema_books failed on unreadable JSON but silently `continue`d over
valid JSON whose top level is not an object or that has no object-valued
`schema` key, so such a book vanished from both the expected set and the
DB and could slip under the 2%/10-book drift gate (--expect-snapshot runs
in no workflow). Both shapes now land in the same `unreadable` accounting:
counted, named with the reason, allowlistable through
KNOWN_UNREADABLE_SCHEMAS like Sheet.json, and otherwise a failure.
Measured against the 2026-09-01 export (6,601 schema files): zero files
take either branch, so this is a no-op today and fail-closed for the next
export that ships a different shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…locked monotonic use stamp; progress and named failures in the patch fan

Squashed from the audit branch by file set (4 original commits contributed; their messages follow).

--- 06b5d0b
fix(ci): durable, digest-verified patch-fan anchor cache; progress and named failures in the patch fan

Audit of cycle 33987355439 (item S8, reports 07/08).

Prefetched patch-fan anchors (~1.3 GB seforim.db.zst each, ~5.2 GB) were
downloaded in the background, deleted by the cleanup step's `rm -rf …
prefetch` when attempt 34021998271 failed 45 min later, and re-downloaded
in full by the successful retry (237+241+232+243 = 953 s, ~249 s wall at
parallel=5, plus downlink contention with the generator). Anchors are
immutable content-addressed release assets.

- prefetch_patch_anchors.sh: durable cache at
  ${XDG_CACHE_HOME:-$HOME/.cache}/seforimlibrary/patch-anchors (the same
  per-runner root the image embedder uses; never the tmpfs — path AND
  `stat -f` filesystem-type guard), keyed by release tag, sha256-verified
  against the release's published digest before every reuse (mismatch,
  republished tag, or missing digest with no recorded sha -> ::warning::
  + fresh download), hardlinked in and out (copy fallback across
  filesystems; a partial copy is removed so gh's no-clobber download
  can proceed), bounded at 8 anchors / 30 days by cache_prune under a
  lock that goes stale after 600 s (a pruner killed mid-run no longer
  disables the bound), applied on all-hit runs too. Tag names are
  validated as single path components (`..` refused). One line per
  anchor: `reused <tag> from cache (sha256 ok)` / `downloaded <tag> in
  Ns`; unwritable cache -> warn and download to the run dir. The
  cleanup step only reports on the cache; S2's temp sweep is disjoint by
  root and by name; `rm -rf prefetch` drops only hardlinks.
- The five bare `ANCHOR` lines are `anchor <tag> (offset N): <asset>
  <size>`; the abort paths of prefetch_patch_anchors.sh and
  upload_early_release_assets.sh say what they aborted and why (still
  never failing the job); a download failure names the missing asset.
- "Produce + verify patch fan" was dark for 22 minutes: numbered
  per-anchor start lines, a per-anchor end line (elapsed, patch size,
  verify result, the folded full-snapshot columns) and a 5-minute
  heartbeat while producing. The launcher command line and anchor
  selection are unchanged.
- QA fixes: the new cache-report call in the if: always() cleanup step
  is guarded (a job dying before the pipeline-control checkout would
  have gone red on exit 127); cache-store failures are ::warning::.

Tests: test_prefetch_patch_anchors.py (cold/warm/corrupted/bound/stale
lock/abort + 8 edge cases: republished tag, absent digest, no-hardlink
fs, partial copy, unwritable root, hardlink survival, age bound,
set -u without HOME), workflow-contract tests for the cache path,
cleanup exclusion, sweep exclusion, progress lines and guarded helper
calls; 123 pytest; 45/45 step bodies bash -n; 0 CR in the diff.

Known limit: the heartbeat lists anchors in batch order, so an anchor
that finishes out of order stays listed until its predecessor lands
(over-inclusive, never a false all-clear).

--- 2350a6c
fix(ci): rank the patch-anchor cache by a monotonic use stamp, not by whole-second mtime

prefetch_patch_anchors.sh pruned by `stat %Y` + `sort -rn`: entries stored
or refreshed within the same second tied, sort fell back to comparing the
rest of the line, and the entry just downloaded (v23-x) could rank below
older ones and be evicted. In the reconcile job the eviction test flaked
0/6 green; in production the cache could discard the anchor it had spent
~16 minutes fetching.

Each entry now records used_ns = max(now_ns, highest used_ns in the cache
+ 1) in .meta on store and on every hit (a Lamport stamp: later writes
always rank higher, whatever the clock or filesystem resolution), and the
pruner sorts by that key with the .meta mtime as the tie-break for pre-fix
entries. The refresh rewrites .meta via temp+rename under $BASHPID (the
five parallel fetches are subshells sharing $$). Keep count, age cutoff,
eviction report line and lock protocol are unchanged; the new tests fail
on the previous script with the defect itself.

--- 1ba52c6
test(ci): gate the anchor-cache sandbox suites on a capability probe instead of failing on BSD tools

The three sandbox classes in test_prefetch_patch_anchors.py drive
prefetch_patch_anchors.sh and the fan step through a real bash and need
mapfile (bash 4), GNU stat --format, GNU sed -i and sha256sum; on macOS
they failed instead of skipping. One cached bash probe at import now
decides HAS_GNU_SANDBOX and the classes skip with the missing capability
named. The probe is by feature, not platform: Windows Git Bash still runs
all 22 tests, and on Linux a probe that comes back false raises instead of
skipping, so a broken probe cannot silently retire the S8/S12 coverage in
the reconcile and contracts jobs.

--- e4956d8
fix(ci): serialise the anchor-cache use stamp under flock; the prune never falls back to tag order

Round 3 of the audit of cycle 33987355439 (item S18).

The five parallel prefetchers allocated `used_ns` as read-max → write with no lock,
so two could get one stamp (reachable on the highest+1 branch or with a %N-less
date), and a full tie made `sort` decide by whole line, i.e. by tag — the freshest
anchor could still be evicted. Allocation now runs under `flock -w 30` on
`<cache>/.stamp.lock`, the floor is persisted in `<cache>/.stamp` so a `.meta` not
yet written cannot be overtaken, the fallback key is `<key>.<BASHPID>` when flock is
unavailable, and the prune sorts stably on three numeric keys.

Tests: STAMP_ALLOC_RACE phase (20 concurrent marks → distinct, strictly increasing
keys; the last-marked anchor survives a keep-1 prune under forced future stamps) and
a rank-contract test on the flock/sort text. Prefetch suite 26/26 on a CR-normalised
copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…light and no-snapshot recovery, named failures in publish and validators, every run: script under GitHub's 21,000-character ceiling

Squashed from the audit branch by file set (7 original commits contributed; their messages follow).

--- 81ab27e
ci(build): make the relink wait observable — heartbeat, a named cause on cancel/failure, and no swallowed poll errors

In run 33991433362 the wait step printed `relink run: 33994031370` and then
nothing for 28,875 s (~480 polls), followed by the generic
`##[error]relink run 33994031370 concluded cancelled`. The child had in
fact hit its own timeout-minutes. Poll stderr went to a file that was only
grepped for HTTP 401 and overwritten each minute; every other API failure
was "transient", fell to `*) queued_for=0` and thereby reset the 30-minute
pre-start stuck cap, so a sustained outage would poll silently to 1440 min.

- One line per 10 minutes: elapsed, child status, current job/step (one
  jobs-API call, tolerated), run URL; the URL is printed once up front.
- completed:cancelled compares the longest job's duration with the child's
  timeout budget (kaggle 90 / local 1440 / server 480, mirrored from
  relink.yml) and says whether the cancel matches it or was external;
  completed:failure lists the failed jobs and steps; every terminal branch
  ends with the recovery hint (`relink-recovery request=… parent=…`, then
  a rebuild with relink_recovery_run_id) and the unchanged ##[error] line.
- Transient poll failures print the first stderr line with a consecutive
  counter, fail the step after 30 in a row with the last stderr, and no
  longer touch queued_for. The 401 fast-fail and the 1800 s cap are
  byte-identical.
- The step's prose no longer describes the split 90m+480m+30m budget for
  the default target=local single job.

Driven under a stubbed gh across success, cancel-at-budget, cancel-early,
failure, timed_out, 31×503, 401 and queued interleaved with 502 (the cap
now fires; it did not before). test_manual_release_workflow.py: 36 tests.

--- d2fb699
fix(ci): recovery mode publishes no snapshot; delete unconsumed snapshot on failure; idempotent gradle.properties; safe temp sweep

Audit of cycle 33987355439 (item S2, report 08-seforimlibrary-build).

- Publish-snapshot step is skipped when relink_recovery_run_id is set.
  A recovery build never dispatches a relink, so nothing ever downloads
  its own rebuilt snapshot; runs 34021998271 and 34024655297 each spent
  464 s uploading a 987 MiB pre-release that was orphaned immediately.
  The dump still runs (Phase-2 semantic compare needs the local bytes).
- build_provenance.json snapshot_sha256 now names the snapshot the link
  set was produced from (EXPECTED_LINKER_SNAPSHOT_ZST_SHA256, which the
  relink step defaults to this build's own digest on every normal path).
  In recovery mode the rebuilt snapshot is unpublished, and both
  standalone-relink consumers (fetch_relink_inputs.sh, relink.yml manual
  mode) hard-fail on a 404 tag, so naming it would break them.
- New failure/cancelled hook deletes this attempt's own
  lines-snapshot-sha256-<sha> pre-release, only when this attempt
  created it (SNAPSHOT_RELEASE_CREATED_HERE, set solely on a successful
  gh release create) AND the relink was never dispatched
  (RELINK_DISPATCH_STARTED unset) AND the relink step was not cancelled
  (GITHUB_ENV application for a cancelled step is undocumented).
  Tag shape is regex-checked; every branch exits 0.
- ~/.gradle/gradle.properties heaps are written as one marked block
  (awk drops any previous block and bare duplicates); the file no longer
  grows every week. Values unchanged; the grep -Fxq gate still matches.
- Cleanup step removes this attempt's lines-snapshot-<run>-<attempt>.db
  and manual-links-inputs-<run>-<attempt>/ with sizes, then a bounded
  -maxdepth 1 -mtime +2 sweep of the same two name shapes. Everything is
  best-effort (|| true): the step is if: always() under bash -e and must
  not turn a published build red.
- Teardown: recovery mode says it dispatches no relink of its own (the
  old text claimed it never reached the dispatch boundary); the closing
  line lists the run ids it actually cancelled.

QA: 51 tests pass, YAML loads, bash -n on all 44 step bodies, deletion
guard exercised in a sandbox with a stub gh (7 branches), gradle block
idempotent over 3 runs. Known accepted gap: a publish step that creates
the release and then fails during upload exports no marker, so that
release leaks rather than risking a wrong delete.

--- c8fb855
fix(ci): preflight the recovery relink inputs at dispatch time; contract test on the Linker's real meta.json layout

Audit of cycle 33987355439 (item S3, report 08-build-failed-34021998271).

Recovery build 34021998271 ran 48 minutes (DB generation, snapshot dump,
a 987 MiB upload) before Phase-2 rejected the Linker's schema-3 meta.json
("recovery snapshot comparison requires schema 2"). The file that failed
was downloadable at second 0: the recovery relink had finished 37 s before
the build started, and meta.json is published as a 759-byte standalone
asset on every linker-release-sha256-* release.

- manual-generate-release.yml: new step "Preflight the recovery relink
  inputs", last step of the reconcile job (ubuntu-latest, before the host
  lease, DB generation, dump and publish), gated on
  relink_recovery_run_id != '' and reuse != 'true'. It walks the same
  chain Phase-2 walks later (run -> display_title -> linker-output-<req>
  -<attempt> -> pointer/manifest -> linker-release-sha256-* -> meta.json
  -> lines-snapshot-sha256-<sha> in this repo) and runs the SAME verifier
  code (--preflight) on the real meta.json. Every refusal is one ::error::
  naming file, field, value seen and expected; success prints exactly one
  line. Nothing in the build job's guard or Phase-2 is weakened or moved.
- verify_relink_recovery_snapshot.py: check_payload_meta() /
  check_baseline_manifest() shared by --preflight and verify(); cheap
  input checks run before the 5 GiB hash; snapshot.sha256 must be 64 hex;
  --preflight combined with the comparison flags is refused (it silently
  skipped the comparison and exited 0 — fail-open, found by QA);
  PAYLOAD_META_SCHEMAS documented as the single source of truth citing the
  Linker writer (src/incremental.py write_meta, schema 3 since a9ae2d4).
- test_verify_relink_recovery_snapshot.py: fixture is the Linker's real
  writer layout (key-for-key); positive test per accepted schema in both
  modes, negative at max+1 naming file and value, both entry points refuse
  identically, tripwire if the accepted set outruns the observed Linker
  schema; sys.executable instead of python3 (13 tests failed on Windows
  before).
- test_manual_release_workflow.py: pins the new step (in the reconcile
  job, its gate incl. the reuse clause, ordered before tmpfs/lease/DB
  generation/dump/Phase-2, one positive line, Phase-2 still runs the full
  verifier without --preflight). 11/11 mutations caught.

QA fixes: `IFS=$'\t' read` collapsed a null conclusion (tab is IFS
whitespace) -> jq `// "none"`; a jq failure in a process substitution
died under set -e without an annotation -> `||` arm. Sandbox: 36 cases
with a stub gh (1 happy path = one line, 35 refusals = one ::error::
each); live read-only replay of run 34021656701 passes every check.
72 tests pass, 45/45 step bodies bash -n clean.

Known limit: the line-baseline manifest (schema 2) lives only inside the
128 MB payload and is still checked at Phase-2.

--- 807b36b
fix(ci): make publish, validators, manifest, dispatch and QA drift observable; gate reference-snapshot drift

Audit of cycle 33987355439 (item S6, report 07-build-success-nongenerate).

- release_draft.sh: the 27-minute publish printed two lines for 3.4 GiB
  across 10 assets. upload_asset now prints one line per asset
  (`uploaded <name> <size> sha256=<12> in <s>s (<MB/s>)`, or `reused …`
  when the content-addressed dedupe skipped a re-send) and a closing
  `release <tag>: N assets, <bytes> bytes, <s>s`. Every failure message,
  retry budget and both gh release upload invocations are byte-identical.
- validate_build_provenance.py, validate_otzaria_provenance.py and
  validate-sefaria-release-metadata.py print exactly one `ok: …` line on
  success (failure contract unchanged: stderr, non-zero, empty stdout);
  the reconcile reuse scan runs the first with --quiet and prints one
  `ok: build_provenance checked on N releases` instead of ~27 lines.
- Release manifest: `gh release view … 2>/dev/null` wrote `latest: null`
  on ANY failure and the step stayed green. Only gh's `release not found`
  / a 404 on a …/releases… URL yields `latest: null` (with an explicit
  line); anything else is ::error:: + exit 1. Same fix in
  update-release-manifest.yml (same bug, same file, same branch); both
  now print the manifest summary, the commit or "no changes".
- Dispatch: one line before/after the otzaria-library repository
  dispatch (a 204 has no body) and one at the relink `gh workflow run`
  (workflow, target, non-secret inputs); the run URL stays with the wait
  loop (pinned to one occurrence).
- QA: reference-snapshot drift was printed and never gated (this cycle:
  baseProvenance=1 12970 vs 13056 = -0.659%, PASS). gate_snapshot_drift
  in scripts/qa/common.py, wired into check1 (total + per type), check2
  and check7: a shrink is ::error:: + exit 1 only when it exceeds BOTH
  QA_DRIFT_MAX_SHRINK_PCT (workflow: 2, a 3x margin over this cycle's
  worst) AND the absolute floor QA_DRIFT_MIN_SHRINK_ABS (workflow: 10;
  without it a -1 on guides=2 / midrash=5 / inferred pairs=20 / targum=45
  would fail the weekly release over one book leaving the export);
  everything else is ::warning:: with the numbers. The gate is off when
  the env is unset, and run_all.py --require-all (release mode, the
  workflow's QA step) fails if it is unset, so it cannot vanish silently.
- Sheet.json: a 0-byte file in the pinned export (Sefaria's source-sheet
  pseudo-index, the only unparseable one of 6,601 schemas; the Kotlin
  importer drops it identically). Now an explicit KNOWN_UNREADABLE_SCHEMAS
  entry with one INFO line; any OTHER unparseable schema is a hard
  failure instead of a silent coverage cut that still reports PASS.
- catalog.pb: never produced by design on this branch (the workflow
  rm -f's stale copies, BRANCH-STACK.md records its removal, the manifest
  omits catalogBlobName), so the 5x WARN is one INFO; an explicitly
  requested missing catalog keeps its WARN.

QA: stub-gh sandboxes for the upload path (incl. 0-byte asset), 9
manifest scenarios on both step bodies, the drift gate matrix (run as a
script), Sheet.json confirmed 0 bytes on a real export; 103 pytest +
QA synthetic matrix pass; 47/47 step bodies bash -n; no S1-S5 hunk
reworked.

--- d5fc42e
fix(ci): keep every run: script under GitHub's 21,000-character expression ceiling

GitHub refused to load manual-generate-release.yml at 15ef349:
"(Line: 1974, Col: 14): Exceeded max expression length 21000" (run
34195296928, zero jobs). S1 and S8 had grown the relink-wait step to 21,230
characters and the patch-fan step to 22,592; the workflow could not run at
all. Two cohesive blocks move out unchanged: the fan's read_schema /
produce_anchor / heartbeat / drain_batch into patch_fan_lib.sh, sourced by
the step (produce_anchor is backgrounded and drain_batch mutates the
driver's arrays, so they stay in the step's shell), and the relink poll loop
into wait_for_relink_run.sh, run as a child with RUN, RUN_URL and
RELINK_REQUEST_ID passed as env (nothing after the loop reads its
variables; it re-states set -euo pipefail, which the step already used).
Largest run: script is now 16,210 characters.

A test asserts every run: script in every workflow stays under 19,000, and
ci.yml gains a hosted `contracts` job running the same Python suite loop
the reconcile job runs, so pull requests get that check even when the
release workflow itself cannot load. test_prefetch_patch_anchors' eviction
sandbox now ages its older entries explicitly instead of relying on
sub-second ordering (the production tie-break is addressed separately).

--- c0ebaf6
fix(ci): give the release parent a budget that outlives its relink child; cap the wait explicitly

build-and-release ran under timeout-minutes: 1440 while the relink child
it dispatches (target=local) has its own 1440-minute ceiling; the comment
already admitted the contradiction. A child legally running to its cap
outlived the parent, GitHub killed the parent, the always() step cancelled
the child, and the week shipped nothing. The parent is now 2880 minutes
(self-hosted jobs may run five days), leaving ~1385 minutes after a
maximal child for Phase-2, the patch fan and the uploads (~130-160
measured); both host-lease TTLs move to 176400 s with the same margin
over the new ceiling. wait_for_relink_run.sh caps the wait at the tighter
of RELINK_WAIT_CAP_MIN and child_timeout_min + 60 and fails with a named
error carrying the recovery recipe, so a child stuck past its own ceiling
surfaces a day before the parent would have died; garbage or missing cap
values still cap, a healthy fast child is never truncated.

The Kotlin workflow contract test pins the same literals and moves with
them; it also still pinned upload-artifact@v4 after S9 moved the workflow
to @v6, which had left the CI test job red on this branch.

--- 1a78e92
fix(ci): the relink wait cap covers the child's whole job chain per path, not one job

Round 3 of the audit of cycle 33987355439 (item S17).

wait_for_relink_run.sh capped the wait at the child's single relink job timeout
plus an hour, but relink.yml is a chain: on target=kaggle relink (90) → resolve (480)
→ publish (30) = 600 minutes, on server 480 + 30, on local 1440 + 30. A healthy
kaggle run was declared out of contract at minute 150 and cancelled by the parent's
cleanup. One table now derives two numbers per path: child_job_max_min (the longest
single job, used only to tell a self-timeout from an external cancel) and
child_path_budget_min (the sum of the path's jobs, the wall clock waited on); the
cap is budget + 60 → 660 / 570 / 1530, and RELINK_WAIT_CAP_MIN moves 1500 → 1530 so
the local path keeps a real margin. The parent's 2880-minute ceiling leaves
≈ 1295 minutes for Phase 2 after the largest cap; the host leases (176400 s) still
outlive the parent by an hour.

Tests: per-path caps replace the assertion that pinned 150; a kaggle child still
running at minute 300 is waited on; the overdue cancel fires at the new cap; the
table's numbers are pinned against relink.yml's timeout-minutes (cross-checked
against a sibling Linker checkout when present).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shes only, test counts in every gate, workflow contract tests incl. the run: length guard, __pycache__ untracked and ignored

Squashed from the audit branch by file set (3 original commits contributed; their messages follow).

--- 15ef349
ci: setup-gradle v5 with real cache reuse; contract.yml on otzaria pushes only; test counts in every gate; native-access flag ends 60 JVM warnings

Audit of cycle 33987355439 (items S9 + S7, reports 12 and 07).

S9
- ci.yml: gradle/gradle-build-action@v3 (deprecated; no inputs; the
  audited run executed 67/67 tasks after a ~400 s cache step) ->
  gradle/actions/setup-gradle@v5 with cache-read-only on non-otzaria
  refs (write on trunk, read on PRs). contract.yml's setup-gradle v4 ->
  v5 so the repo runs one generation (test pins them equal). v6 is not
  taken: it moves caching into a proprietary component under commercial
  terms and drops configuration-cache support, which this repo enables
  (operator decision).
- contract.yml: `push: branches: [otzaria]` (+ unfiltered pull_request);
  the pipeline's own release/handoff refs no longer re-trigger it. No
  tag trigger existed, nothing in the four repos looks up a contract
  run, and the trunk has no branch protection, so no required check is
  lost.
- Every Python-suite gate prints `tests: <suite> N passed` per suite and
  a total, and fails on a failing suite AND on a suite collecting zero
  tests; ci.yml and contract.yml count the JUnit XML (`tests: allTests …`)
  and fail if no XML exists. The reconcile job's loop now also runs
  test_ci_workflows.py, test_patch_anchor_schema.py and
  test_prefetch_patch_anchors.py (hermetic: stub gh, no network) — S8's
  prefetch work was gated by nothing before; a contract test asserts the
  set of test_*.py files on disk equals the wired set.
- Node 20 deprecations: every ubuntu-latest job moves to the lowest
  node24 major (checkout v5, setup-java v5, upload-artifact v6,
  setup-gradle v5), each verified from the action's action.yml; self-
  hosted jobs untouched. The "Gradle-8" line is KGP's own inert wording
  (0 .java sources) and is explained in ci.yml rather than silenced.

S7
- The 60 `WARNING: A restricted method in java.lang.System has been
  called … System::load` lines were 15 forked-JVM blocks from
  org.sqlite.SQLiteJDBCLoader (Generate x9, Dump lines, Apply LINKER,
  patch fan x4). `--enable-native-access=ALL-UNNAMED` is applied
  centrally via a CommandLineArgumentProvider on every JavaExec/Test
  task in the root build (not jvmArgs, which ~25 tasks assign and two
  lines of which the recovery overlay greps verbatim) and via
  patchPipelineJvmArgs for the fan's direct java. Proven: 4 warnings ->
  0 on the same class; a real JavaExec reaching NativeDB -> 0; the
  launcher spec publishes the flag; configuration cache stored and
  reused.
- `--warning-mode all` on ci.yml's single Gradle invocation: exactly two
  deprecations, both from the Android Gradle plugin (lint-gradle, aapt2),
  none in this repo's build scripts.
- The remaining gh stderr leak (prefetch's unexpected seforim.db.zst
  failure) folds gh's reason into its named line.
- Not done, by decision: moving the 45 `run:` bodies (2,403 lines, ~127
  KB of echoed script per log) to files before Thursday.

QA: action majors and `with:` keys verified per action.yml; loops
executed (failing suite, zero-collection, empty XML dir); 137 pytest;
108 step bodies across 9 workflows bash -n; 0 CR in the diff.

--- f0c95a1
chore: untrack .github/scripts/__pycache__ (added by mistake in 807b36b) and ignore it

--- 3772bac
Merge origin/otzaria (PR #27 per-table content hash) into fix/audit-cycle-33987355439

contract.yml was the only conflict: base added the logical-hash fixture pair
(env, test -f, cmp, --tests '*LogicalHashContractTest*'); this branch added
the otzaria push filter, the @v5 action majors and the JUnit-XML test count.
Resolved by keeping both: 6 fixtures present, 3 byte-identical pairs, and the
count line labels both test classes. test_ci_workflows.py pins those literals,
so its three assertions move in lockstep (4→6, 2→3, combined label).

PatchPipelineCli.kt auto-merged (non-overlapping hunks); no script in .github/
parses the delta manifest's new fromTableContentHashes/toTableContentHashes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@palmoni5
palmoni5 force-pushed the fix/audit-cycle-33987355439 branch from 5888ffd to 14b5413 Compare September 9, 2026 09:14
@Y-PLONI
Y-PLONI merged commit b6d7210 into otzaria Sep 9, 2026
3 checks passed
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