v0.2.13
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 14:23:13 +01:00
1 parent 83440f20e2
commit 12e0c99850
32 files changed
+729 -109

No files matched your search

+61 -22
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Static/offline release checks for RustBox 0.2.12.
"""Static/offline release checks for RustBox 0.2.13.
These checks intentionally avoid pretending to replace `cargo test`: they validate
configuration, SQL migrations, Docker examples, query-catalog parity and release
@@ -49,14 +49,14 @@ def extract_section(text: str, begin: str, end: str) -> str:
# Release/toolchain metadata.
cargo = tomllib.loads(read("Cargo.toml"))
require(cargo["package"]["version"] == "0.2.12", "Cargo package version is 0.2.12")
require(cargo["package"]["version"] == "0.2.13", "Cargo package version is 0.2.13")
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
toolchain = tomllib.loads(read("rust-toolchain.toml"))
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
lock = tomllib.loads(read("Cargo.lock"))
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
require(lock_rustbox.get("version") == "0.2.12", "Cargo.lock RustBox package version is 0.2.12")
require(lock_rustbox.get("version") == "0.2.13", "Cargo.lock RustBox package version is 0.2.13")
require(cargo["dependencies"].get("encoding_rs") == "0.8", "direct charset decoder dependency is pinned")
require("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency")
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
@@ -71,10 +71,10 @@ require("make_zip.py" in package_script and "sha256sum" in package_script, "rele
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
require(read("README.md").startswith("# RustBox 0.2.12"), "README identifies RustBox 0.2.12")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.12"), "release manifest identifies RustBox 0.2.12")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.12"), "release validation identifies RustBox 0.2.12")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.12"), "implementation status identifies RustBox 0.2.12")
require(read("README.md").startswith("# RustBox 0.2.13"), "README identifies RustBox 0.2.13")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.13"), "release manifest identifies RustBox 0.2.13")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.13"), "release validation identifies RustBox 0.2.13")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.13"), "implementation status identifies RustBox 0.2.13")
bootstrap_js = read("src/web/js/00-bootstrap.js")
bindings_js = read("src/web/js/95-bindings-boot.js")
realtime_js = read("src/web/js/80-settings-realtime.js")
@@ -124,8 +124,8 @@ require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'dec
require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only")
index_html = read("src/web/index.html")
require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details")
require("V048" in read("RELEASE-MANIFEST.txt") and "V049" in read("RELEASE-MANIFEST.txt"), "release manifest documents V048 and reserves V049")
require("V048" in read("migrations/README.md") and "V049" in read("migrations/README.md"), "migration guide documents V048 and reserves V049")
require("V049" in read("RELEASE-MANIFEST.txt") and "V050" in read("RELEASE-MANIFEST.txt"), "release manifest documents V049 and reserves V050")
require("V049" in read("migrations/README.md") and "V050" in read("migrations/README.md"), "migration guide documents V049 and reserves V050")
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
@@ -521,6 +521,10 @@ try:
require(outbox is not None, "fresh SQLite schema has search_outbox")
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
blob_outbox = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='u_test__blob_delete_outbox'").fetchone()
require(blob_outbox is not None, "fresh SQLite schema has blob_delete_outbox")
blob_outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__blob_delete_outbox)")}
require({"storage_key", "attempts", "available_at", "claimed_until", "last_error"}.issubset(blob_outbox_columns), "fresh SQLite blob deletion outbox has retry/lease state")
folder_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__folders)")}
require("counter_generation" in folder_columns, "fresh SQLite schema has V048 folder counter generation")
sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
@@ -595,12 +599,26 @@ try:
except Exception as exc:
errors.append(f"SQLite V048 migration execution failed: {exc}")
# V049 creates the durable blob deletion outbox.
try:
conn = sqlite3.connect(":memory:")
migration = read("migrations/sqlite/V049__blob_delete_outbox.sql").replace("{{p}}", "u_test__")
conn.executescript(migration)
columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__blob_delete_outbox)")}
require({"id", "storage_key", "attempts", "available_at", "claimed_until", "last_error", "created_at", "updated_at"}.issubset(columns), "V049 creates SQLite blob_delete_outbox with durable retry state")
indexes = {row[1] for row in conn.execute("PRAGMA index_list(u_test__blob_delete_outbox)")}
require(any("blob_delete_outbox_ready" in name for name in indexes), "V049 creates SQLite blob cleanup readiness index")
conn.close()
except Exception as exc:
errors.append(f"SQLite V049 migration execution failed: {exc}")
# Migration/query parity across SQL engines.
for dialect in ("sqlite", "mysql", "pgsql"):
require((ROOT / f"migrations/{dialect}/V047__blob_keys_and_search_outbox.sql").is_file(), f"V047 migration exists for {dialect}")
require((ROOT / f"migrations/{dialect}/V048__folder_counter_generation.sql").is_file(), f"V048 migration exists for {dialect}")
require((ROOT / f"migrations/{dialect}/V049__blob_delete_outbox.sql").is_file(), f"V049 migration exists for {dialect}")
schema_text = read(f"migrations/{dialect}/schema.sql")
require("Current runtime schema: V048" in schema_text and "counter_generation" in schema_text, f"fresh {dialect} schema includes V048 folder counter generation")
require("Current runtime schema: V049" in schema_text and "counter_generation" in schema_text and "blob_delete_outbox" in schema_text, f"fresh {dialect} schema includes V049 blob deletion outbox")
queries = read(f"src/queries/{dialect}/queries.rs")
folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S)
require(folder_block is not None and "Q_032" in folder_block.group(1) and "counter_generation = counter_generation + 1" in folder_block.group(1), f"folder counter CAS increments generation for {dialect}")
@@ -609,11 +627,17 @@ for dialect in ("sqlite", "mysql", "pgsql"):
if block_match:
qnames = set(re.findall(r"pub const (Q_\d{3})", block_match.group(1)))
require(qnames == {f"Q_{i:03d}" for i in range(1, 8)}, f"search outbox Q001-Q007 parity for {dialect}")
blob_block = re.search(r"pub mod db_blob_delete_outbox\s*\{(.*?)\n\}", queries, re.S)
require(blob_block is not None, f"blob deletion outbox query module exists for {dialect}")
if blob_block:
blob_qnames = set(re.findall(r"pub const (Q_\d{3})", blob_block.group(1)))
require(blob_qnames == {f"Q_{i:03d}" for i in range(1, 6)}, f"blob deletion outbox Q001-Q005 parity for {dialect}")
pool = read("src/db/pool.rs")
for dialect in ("SQLITE", "MYSQL", "PGSQL"):
require(f"{dialect}_MIGRATIONS" in pool and "V047__blob_keys_and_search_outbox.sql" in pool, f"runtime migration catalog includes V047 ({dialect.lower()})")
require(f"{dialect}_MIGRATIONS" in pool and "V048__folder_counter_generation.sql" in pool, f"runtime migration catalog includes V048 ({dialect.lower()})")
require(f"{dialect}_MIGRATIONS" in pool and "V049__blob_delete_outbox.sql" in pool, f"runtime migration catalog includes V049 ({dialect.lower()})")
require("const BASE_SCHEMA_VERSION: u32 = 46" in pool, "legacy squashed baseline stays V046")
require("latest_schema_version" in pool, "runtime schema version is computed from migrations")
require(
@@ -706,9 +730,9 @@ require("storage.shared" in config_rs, "cluster-safe shared POSIX storage is cap
require("AmazonS3Builder" in storage_rs and "BufWriter" in storage_rs, "S3 backend uses streaming object-store writer")
require("with_retry" in storage_rs and "with_checksum_algorithm" in storage_rs, "S3 backend configures retries and checksums")
require("attachment_key" in storage_rs and "users/{user_hash}/drafts/{draft_id}/attachments/{attachment_id}" in storage_rs, "attachments use logical storage keys")
require("destination.delete(&target_key).await" in storage_rs, "attachment migration removes a failed destination blob after verification failure")
require("enqueue_unreferenced_delete(&conn, destination.as_ref(), &target_key).await" in storage_rs, "attachment migration durably queues failed destination blob cleanup")
require("let _ = upload.abort().await" in storage_rs and "failed reading source" in storage_rs, "attachment migration aborts a destination upload if source streaming fails")
require("if let Err(error) = crate::db::drafts::update_attachment_storage" in storage_rs, "attachment migration removes destination object when SQL pointer update fails")
require("if let Err(error) = crate::db::drafts::update_attachment_storage" in storage_rs and "Failed to queue storage migration cleanup" in storage_rs, "attachment migration queues destination cleanup when SQL pointer update fails")
require("OpenSearchBackend" in search_rs, "OpenSearch backend is implemented")
require("reqwest::blocking" not in search_rs and 'features = ["json", "rustls-tls"]' in read("Cargo.toml"), "OpenSearch uses the async reqwest client without blocking feature")
require("async fn request(" in search_rs and "request.send().await" in search_rs, "OpenSearch HTTP requests are asynchronous")
@@ -717,6 +741,15 @@ require("process_search_outbox" in search_rs and "claim(&conn" in search_rs, "Op
require("fn event_lease_secs(&self) -> u64" in search_rs and "let lease_secs = backend.event_lease_secs();" in search_rs and "claim(&conn, &event, 60)" not in search_rs, "OpenSearch outbox lease is derived from HTTP timeout/failover budget instead of a fixed 60 seconds")
require("opensearch_event_lease_covers_timeout_and_failover_budget" in search_rs, "OpenSearch lease budget has a source-level unit regression test")
require("claimed_until" in read("migrations/sqlite/V047__blob_keys_and_search_outbox.sql") and "reenqueue_preserves_active_claim_lease" in read("src/db/search_outbox.rs"), "outbox re-enqueue cannot steal an active worker lease")
blob_delete_rs = read("src/db/blob_delete_outbox.rs")
attachments_rs = read("src/routes/attachments.rs")
send_rs = read("src/routes/send.rs")
require("mark_failure" in blob_delete_rs and "min(3600)" in blob_delete_rs and "max_attempts" not in blob_delete_rs and "dead_letter" not in read("migrations/sqlite/V049__blob_delete_outbox.sql"), "blob deletion outbox retries indefinitely with capped backoff and no permanent dead-letter")
require("unchecked_transaction()" in attachments_rs and "blob_delete_outbox::enqueue(&transaction" in attachments_rs and "delete_draft_attachment(&transaction" in attachments_rs, "single attachment deletion atomically queues blob cleanup with metadata deletion")
require("blob_delete_outbox::enqueue(&transaction" in drafts_rs and "delete_draft(&transaction" in drafts_rs, "whole-draft deletion atomically queues every attachment blob before metadata cascade")
require("blob_delete_outbox::enqueue(&transaction" in send_rs and "delete_draft(&transaction" in send_rs, "post-send draft cleanup atomically queues attachment blobs")
require("start_blob_delete_worker" in storage_rs and "process_blob_delete_outbox" in storage_rs and "start_blob_delete_worker(config.clone(), storage.clone())" in read("src/main.rs"), "durable blob deletion worker is wired at startup")
require('let key = "_health/probe".to_string();' in storage_rs, "storage health probe uses a fixed key and cannot accumulate unique orphan probe objects")
require("index_messages_batch_with_connection" in search_rs and "enqueue_upsert_on" in search_rs, "OpenSearch outbox supports caller-owned SQL transactions")
require("enqueue_participates_in_caller_transaction" in read("src/db/search_outbox.rs"), "transactional outbox rollback has a unit test")
require("search reindex" in read("README.md") and "run_search_reindex" in read("src/main.rs"), "full search reindex command is documented and wired")
@@ -734,19 +767,25 @@ require("docs/RUSTBOX_PLAN.md" not in read("docs/IMPLEMENTATION_STATUS.md"), "im
require("intentionally run one RustBox application container" in read("docker/README.md"), "Docker docs distinguish shared backing topology from multi-app load balancing")
attachments_rs = read("src/routes/attachments.rs")
require(".begin_upload(&key)" in attachments_rs and ".get(&attachment.storage_key)" in attachments_rs and (".delete(&attachment.storage_key)" in attachments_rs or ".delete(&storage_key)" in attachments_rs), "attachment HTTP routes use BlobStorage")
require(".begin_upload(&key)" in attachments_rs and ".get(&attachment.storage_key)" in attachments_rs and "storage::process_queued_delete" in attachments_rs, "attachment HTTP routes use BlobStorage")
require("tokio::fs::" not in attachments_rs, "attachment HTTP routes no longer access local filesystem directly")
attachment_db_delete = attachments_rs.find("db::drafts::delete_draft_attachment")
attachment_blob_delete = attachments_rs.find("storage.delete(&storage_key)", attachment_db_delete)
require(attachment_db_delete >= 0 and attachment_blob_delete > attachment_db_delete, "attachment delete commits DB removal before best-effort blob cleanup")
attachment_enqueue = attachments_rs.find("blob_delete_outbox::enqueue(&transaction")
attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue)
attachment_commit = attachments_rs.find(".commit()", attachment_db_delete)
attachment_blob_delete = attachments_rs.find("storage::process_queued_delete", attachment_commit)
require(0 <= attachment_enqueue < attachment_db_delete < attachment_commit < attachment_blob_delete, "attachment delete commits metadata removal and cleanup enqueue atomically before physical blob deletion")
drafts_rs = read("src/routes/drafts.rs")
draft_db_delete = drafts_rs.find("db::drafts::delete_draft(&conn, &id)")
draft_blob_delete = drafts_rs.find("storage.delete(&attachment.storage_key)", draft_db_delete)
require(draft_db_delete >= 0 and draft_blob_delete > draft_db_delete, "draft delete commits DB removal before best-effort blob cleanup")
draft_enqueue = drafts_rs.find("blob_delete_outbox::enqueue(&transaction")
draft_db_delete = drafts_rs.find("delete_draft(&transaction", draft_enqueue)
draft_commit = drafts_rs.find(".commit()", draft_db_delete)
draft_blob_delete = drafts_rs.find("storage::process_queued_delete", draft_commit)
require(0 <= draft_enqueue < draft_db_delete < draft_commit < draft_blob_delete, "draft delete commits metadata cascade and cleanup enqueue atomically before physical blob deletion")
send_rs = read("src/routes/send.rs")
send_db_delete = send_rs.find("db::drafts::delete_draft(&conn, draft_id)")
send_blob_delete = send_rs.find("storage.delete(&attachment.storage_key)", send_db_delete)
require(send_db_delete >= 0 and send_blob_delete > send_db_delete, "post-send cleanup commits DB removal before best-effort blob cleanup")
send_enqueue = send_rs.find("blob_delete_outbox::enqueue(&transaction")
send_db_delete = send_rs.find("delete_draft(&transaction", send_enqueue)
send_commit = send_rs.find("transaction.commit()", send_db_delete)
send_blob_delete = send_rs.find("storage::process_queued_delete", send_commit)
require(0 <= send_enqueue < send_db_delete < send_commit < send_blob_delete, "post-send cleanup commits metadata removal and cleanup enqueue atomically before physical blob deletion")
# Repository/source-package hygiene independent of Cargo.
source_files = [path for path in ROOT.rglob("*") if path.is_file() and ".git" not in path.parts and "target" not in path.parts]