Skip to content

ZigBase 0.13.0

Latest

Choose a tag to compare

@github-actions github-actions released this 09 Aug 22:56
· 181 commits to main since this release
Immutable release. Only release title and notes can be modified.
1d200f7

[0.13.0] - 2026-08-09

Breaking

  • The API error envelope changed shape. It is now {"status": <int>, "code": "<string>", "message": "…", "data": {…}} — the top-level code used to repeat the integer HTTP status and is now a frozen machine string (the integer moved to status). The three divergent shapes are gone: typed (rpc.*) routes, auth-method endpoints, and ctx.jsonError all emit this one envelope instead of the old bare {"message": …} and {"error": …} bodies. Branch on code; message text is not contract. Every built-in error response now carries a code that matches its status (401 → unauthorized, 403 → forbidden, 404 → not_found, 409 → conflict, 429 → too_many_requests, etc.) instead of silently defaulting to internal at ~30 call sites across the auth, files, mail, accounts, senders, and analytics APIs. The official SDKs read message and data only and need no change.
  • ctx.jsonError(status, code) now takes a message: ctx.jsonError(status, code, message).
  • zigbase serve now takes an exclusive lock on its data dir and refuses to start when
    another serve process already owns it. Two servers sharing one data dir silently
    half-worked before (two JWT secrets, two schedulers running the same cron, two
    provisioners racing the same DDL). Pass --ignore-lock for the old behavior — that
    instance is untracked and invisible to zigbase serve status/stop/logs.

Features

  • GET /api/meta — a public, unauthenticated capability probe reporting the running version and commit, which optional route groups this binary carries (admin, OAuth2, WebAuthn, magic link, tenancy, analytics, senders, mail webhook/unsubscribe), which build flags are on (Postgres, S3, vector, dev mode), whether collection metadata is frozen, where the public feature-state route is mounted, and the upload size limit. Tools no longer have to infer frozen mode by string-matching a 403. It exposes only facts an anonymous client could already establish by probing — never a config value, path, or credential — and it is deliberately separate from /api/health (liveness) and /api/state (per-subject feature flags).
  • zigbase version --json and zigbase migrate status --json emit exactly one JSON object on stdout (prose and warnings go to stderr), for scripts and agents that would otherwise parse the human report.
  • Added -Ddev-tools (default on): a build flag that comptime-gates the three pure development-time CLI verbs init, agents-md, and typegen (project scaffolding + schema-to-client codegen — src/scaffold*.zig and src/codegen/**, ~490 KiB in a ReleaseSafe, stripped binary). Every artifact we publish — the GitHub release tarballs, the Docker image, and the @zigbase/server npm packages — builds at this default and ships all three; -Ddev-tools=false is an opt-out for a consumer compiling their own binary for their own deployment who has no use for scaffolding or codegen on a running server. A binary built that way still recognizes the verb names but exits non-zero with a message pointing back at -Ddev-tools=true instead of UnknownCommand.
  • zigbase doctor runs nine preflight checks over a deployment — JWT-secret
    persistence, every @public access rule enumerated by name, cookie security, bind
    address, reverse-proxy coherence, mailer configuration, pending migrations, data-dir
    writability, and auth records still carrying a legacy password hash — and exits 1
    when any of them is an error, 2 when it found warnings only, and 0 when the
    deployment is fully clean. --production judges the
    same facts as a production deployment, escalating the risky warnings (an anonymously
    writable collection, insecure cookies, an unconfigured mailer, a spoofable
    --trust-proxy) into errors. --json emits NDJSON findings plus one summary object, and
    the check ids are frozen, so a script can match on them forever. It never opens or
    creates anything under a data dir it already found unwritable — the two DB-backed
    checks report skipped instead — so a read-only diagnostic never mutates a deployment
    it can't safely reach.
    The legacy-password-hash check stays a warning even under --production: a legacy
    hash is a normal transitional state that re-hashes itself on the owner's next
    successful login, so what a migrating operator wants is to watch the count fall to
    zero, not to be blocked from deploying.
  • Two new frozen error codes. payload_too_large replaces a generic internal on the 413 an over-size upload returns — a client fixes that by sending less data, so it must never be indistinguishable from a server fault. email_not_verified replaces a generic forbidden when login succeeds against an account whose email is unverified, so a client can route to its verify-email flow by matching a code instead of the message text.
  • The error code is now exposed on every SDK's error type — ZigbaseError.code (TypeScript, Python) and ZigbaseException.code (Dart, Kotlin). This is the handle that makes the frozen registry usable from a client: branch on code, never on message. It is empty when the server sent no code, and a pre-unification integer code is ignored rather than surfaced as one.
  • Error codes are now a frozen, documented registry (src/error-codes.frozen): every code ZigBase emits — the ten top-level envelope codes and the 27 field-level validation_* codes — is append-only and permanent, enforced by unit tests and by a CI guard that diffs the ledger against the base branch so a code can never be quietly deleted. Match on code; message text is explicitly not part of the contract and may be reworded at any time.
  • zigbase explain-code [CODE] [--json] — print the summary and long-form explanation for any frozen API error code, or list every code with explain-code alone. --json emits exactly one JSON object on stdout (prose goes to stderr); the exit code is 0 for a registered code and 1 for an unknown one.
  • zigbase import now supports migration-scale NDJSON loads: --dry-run executes and validates every row through the full engine (defaults, encryption, auth transforms) then rolls back instead of committing, so a rehearsal writes nothing; --continue-on-error isolates each row in its own SAVEPOINT and skips a failing one instead of aborting the whole run, logging its finding to --error-log as NDJSON ({"line":N,"code":…,"detail":…}); --progress N prints a heartbeat to stderr; --json prints the run summary as one JSON object on stdout (created/updated/failed/total). A lossy import (failed > 0) now exits 3, never 0, so an agent or script cannot mistake skipped rows for success.
  • zigbase import --manifest FILE loads a whole dataset — several NDJSON files, one per collection — in one command. Collections load in relation order (from a JSON manifest: {"zigbaseImportManifest":1,"collections":[{"collection":"authors","file":"authors.ndjson"},{"collection":"posts","file":"posts.ndjson","upsertKey":"slug"}]}), with file paths resolving against the manifest's own directory so a migration bundle is relocatable. Relation cycles and self-relations — which defeat any static load order — are loaded with the offending values stripped, then patched in by record id once every target row exists (a deferred row must carry its own id). A required field on one of those relations has no legal two-pass row order, so it's refused up front instead of failing row by row. --manifest is mutually exclusive with --collection/--upsert-key/the positional file (the manifest supplies those per entry) and composes with the existing --dry-run/--continue-on-error/--error-log/--progress/--json flags. See docs/migration-tools.md.
  • zigbase import --legacy-hashes bcrypt imports users with their existing bcrypt password hashes, stored tagged as $zblegacy$bcrypt$<hash> (e.g. $zblegacy$bcrypt$$2b$10$... — the tag's
    trailing $ immediately precedes the bcrypt hash's own leading $). On the user's first successful login the credential is verified against the source hash and transparently rewritten as argon2id. $2a$, $2b$ and $2y$ hashes are accepted ($2x$ is refused — its deliberately-buggy 8-bit handling is not reproduced here). Requires an auth collection, requires source ids to be preserved (the credential is matched to its row by id), refuses _superusers, and carries each row's verified flag over so a cutover does not mail a verification demand to the whole user base. It is create-only: combining it with --upsert-key (or a manifest entry's upsertKey) is refused, because an updated row would land with no credential installed. See docs/migration-tools.md.
  • --log-format text|json / ZIGBASE_LOG_FORMAT switches the whole log stream to one JSON object per line on stderr, and --log-level / ZIGBASE_LOG_LEVEL sets the minimum severity (debug, info, warn, error). The env vars apply to every subcommand; the flags are serve-only.
  • New guide: Observability & machine-readable output (docs/observability.md) — the log formats, the NDJSON consumption rule, the frozen error-code registry, and the --json CLI conventions.
  • The server is now distributed under the bare npm name zigbase in addition to @zigbase/server, so npx zigbase serve … works and require("zigbase") re-exports binaryPath(). The alias ships no binary and pins one exact @zigbase/server version, which stays the canonical package to depend on; installing both is harmless even though both provide a zigbase command. The unscoped name is claimed by a one-time manual publish — see clients/typescript/npm/RELEASING.md.
  • Added tools/replay/zb_replay.py, a dependency-free parity-replay harness: record a backend's HTTP behaviour, replay it against its replacement, and diff. Matching is a recursive subset with volatile keys (ids, timestamps, tokens) stripped at record time; an expectation of null still requires the key to exist, so a migration that silently drops a field is caught rather than passing. Findings are NDJSON, the summary is one JSON object on stdout, and a parity failure exits 2 (a fully-dead replay target exits 1 instead — nothing was actually exercised). See docs/migration-tools.md.
  • Per-request access logging: every HTTP request now emits one line with its method, path, status, and duration — as a structured record under --log-format json, or GET /api/health 200 3ms in text mode. Turn it off with --no-request-log / ZIGBASE_LOG_REQUESTS=false when a reverse proxy already ships access logs.
  • zigbase schema dump [--json] [--out FILE] writes a canonical, deterministic JSON document of every non-system collection — fields with their stable ids, indexes, access rules and options — that diffs cleanly in git. OAuth client secrets are redacted, so it is not a secrets backup.
  • zigbase schema apply FILE [--dry-run] [--allow-destructive] [--prune] executes the difference between a document and the live schema through the same validation and DDL path as the REST collections API. dump → apply is a no-op, and stays one across repeated re-applies of an already-converged document — including one with a relation cycle. Destructive changes (drops, retypes) are refused without --allow-destructive; --dry-run exits 2 when it finds them. Collections absent from the document are left alone unless --prune. Refused under .collections_frozen. See docs/migration-tools.md.
  • zigbase schema check-rules [FILE] lints access-rule expressions, which nothing validated when they were written: rules were bound into _collections as opaque strings and first parsed by the request that had to evaluate them, where a parse failure fails closed (500) — so a typo shipped silently and broke the first request touching the collection. The linter runs each rule through the real pipeline (no second grammar): given a data dir it is full depth (rules.compileGuard, the request path's own entry point — catches unknown fields and bad relation traversals as well as syntax); given a document it is syntax depth (lexer + parser only, since resolving a field name needs a live schema), and every run states which depth ran. Blank rules (null/"") are Locked and never reported; a rule of exactly "@public" is a warning. Output is doctor-shaped NDJSON findings plus one summary, exiting 0/2/1 for clean/warnings-only/errors.
  • zigbase schema apply now syntax-checks every access rule in the document before it writes anything, and refuses the entire apply — nothing written, offending collection/rule/error code on stderr, exit 1 — if any of them fails to parse. It runs in --dry-run too and ahead of the destructive check, so a document that is both unparseable and destructive exits 1 rather than 2. The gate is syntax-only and has no opt-out: it does not resolve field or relation names (a rule may legitimately name a field the same apply is about to add) and does not report @public (apply's exit 2 is frozen as "dry-run found destructive changes"). Those judgment-shaped findings stay in schema check-rules.
  • zigbase serve --background detaches the server into its own process group, writing
    its output to <data-dir>/serve.log and exiting 0 only once the server actually
    answers GET /api/health — so a script or agent can start a server and immediately
    use it. Manage it with zigbase serve status [--json], zigbase serve stop
    (idempotent), and zigbase serve logs [--follow]. Liveness is an flock(2) held for
    the process lifetime, so a kill -9'd session is detected as gone rather than
    lingering as a stale pid file.
  • A detected AI-agent environment (CLAUDECODE, CODEX_THREAD_ID, GEMINI_CLI, and
    the rest of the usual table) makes zigbase serve background itself automatically,
    printing which provider was detected and how to turn it off. Set
    ZIGBASE_SERVE_BACKGROUND=0 to opt out, or =1 to force background mode anywhere.
  • zigbase serve --ephemeral starts a throwaway server on a fresh temp data dir and a
    free port, printing one JSON object — {"url","port","data_dir","pid"} — on stdout
    once it is actually answering. This is the zero-Zig test-backend story: an SDK test
    suite or a frontend dev script can spawn a real ZigBase, read one line, and use it.
    It composes with --background, and the temp dir is deleted on graceful shutdown and
    by zigbase serve stop.
  • zigbase serve logs --json prints only the structured NDJSON records from serve.log,
    dropping the plain-text startup banner the HTTP layer writes to the same file — so
    zigbase serve logs --json | jq works against a real log file instead of failing on the
    first non-JSON line. It composes with --follow, and says so on stderr when the file
    holds no records at all (the usual cause being a session started without
    --log-format json).
  • zigbase init scaffolds a starting-point project in one command — --box (no Zig toolchain: docker-compose.yml, a schema/collections.json document, AGENTS.md/CLAUDE.md, .gitignore, README.md) or --framework (a Zig package with build.zig, build.zig.zon, a comptime schema, and in-process tests already wired). Box mode's schema is a {"zigbaseSchema": 1, "collections": [...]} document you apply with zigbase schema apply — the same declarative surface docs/migration-tools.md documents, needing no superuser session token and no hand-scripted auth dance; docker-compose.yml carries a read-only ./schema:/schema:ro bind mount so docker compose exec zigbase /zigbase schema apply /schema/collections.json works out of the box. Existing files are never overwritten — they are reported as skipped, and there is no --force. Reachable with no install at all via npx zigbase init.
  • zigbase agents-md writes a trap-oriented AGENTS.md (plus a one-line CLAUDE.md) into a project that already exists, inferring box vs framework content from the directory. --stdout prints instead of writing, for diffing.
  • New build helpers on ZigBase's build.zig, usable from a consumer's build.zig via @import("zigbase"): addTo(dep, mod) adds the zigbase import and sets link_libc together, so the two cannot drift apart; addTest(b, dep, .{ .root_module = … }) creates a test artifact wired with a .simple-mode test runner ZigBase now ships, which avoids the upstream Zig 0.16 --listen=- build-runner race and fails the build on a leaked allocation. docs/framework.md §2 and the README now lead with addTo instead of a separate addImport + "remember link_libc" pair, and §15 hands out addTest in place of the previous advice to copy Zig's own test runner into your project.
  • New docs: Testing — which test surface covers what, the build wiring, and what an in-process test structurally cannot see; and For coding agents — a ~2k-token entry point.
  • The docs site now publishes llms.txt and a machine-readable docs-index.json, both generated from the same registry that drives the published pages.
  • Structured logging. Every log line is now timestamped and leveled, and the whole stream can be switched to one JSON object per line. Embedding consumers opt in from their own binary root with pub const std_options = zigbase.std_options;.

Fixes

  • A 5xx from an auth method no longer forwards its internal message to the caller. The frozen registry documents internal as leaking no detail; the detail now goes to the log, and the response carries the generic body. The same applies to WebAuthn's misconfiguration responses, which previously told an anonymous caller whether a collection's rp_id/origin were set.
  • A static-file read that fails for an internal reason (OOM) is now reported as a 500 incident instead of a silent 404. Genuine filesystem misses still return 404 without raising an incident, unchanged.
  • A request that runs out of memory while parsing a multipart body now answers 500 instead of dropping the connection with no response at all (which also logged a status the client never received).
  • The full-text provisioner no longer skips a collection in silence. A collection whose name is not a valid identifier is still not indexed — the read path deliberately answers ?search= on it with NotSearchable rather than returning unfiltered rows — but when such a collection actually declares .searchable fields, startup now logs what was skipped, why, and how to fix it, instead of leaving ?search= failing with nothing in the logs.
  • Record reads of the _-prefixed system collections (_superusers, _memberships, _invitations, _events, …) no longer degrade silently. Their names fail the schema.isValidIdentifier charset gate (it requires an alphabetic first byte), which made records.getAtRest report a phantom "record not found" — dropping the cross-instance realtime DELETE authorization snapshot on Postgres, so subscribers on other instances silently missed those deletes — and made all three TTL paths (get, list, and the _ttl_gc sweep) skip their expiry handling, which would have served expired rows and never reaped them. Every one of these now escapes the identifier via ddl.quoteIdent (the discipline already used for column names), so the read, list, and GC paths agree; the charset gate stays where it belongs: on user-supplied names at creation time.
  • Corrected the documented identifier-safety model. CLAUDE.md and docs/security-audit.md both stated that every interpolated SQL identifier is gated through schema.isValidIdentifier — which the query layer never calls. The guarantee is real but is three mechanisms, not one: user-supplied names are charset-gated at creation; ?filter=/?sort= path segments are membership-checked against the collection schema (an exact-name whitelist, stronger than a charset check); and engine-owned names are escaped at interpolation. Both documents now describe what the code actually does, so a reader assessing injection risk is not reasoning from an inaccurate model.
  • A running server now notices collection changes made by another process. zigbase schema apply,
    zigbase migrate, zigbase import, and zigbase migrate-db mutate a data dir that a server may be serving from,
    but the collection-metadata cache had no TTL and was invalidated only by this process's own REST
    DDL — so the server kept serving stale definitions (stale access rules included) until it was
    restarted. Because negative lookups were cached too, a newly created collection kept returning
    404 rather than merely looking out of date. A one-row _schema_state generation marker is now
    bumped by every engine write to _collections, inside that write's own transaction, and a
    background observer drops the cache within 5 seconds of the value changing. No new config key and
    no new environment variable.
  • Hand-written metadata SQL (e.g. UPDATE "_collections" … via ctx.records().queryAs()) can
    announce itself with the new ctx.markSchemaChanged() / tx.markSchemaChanged(); see
    framework.md.
  • A failing built-in endpoint no longer returns an unexplained 500 in silence. Errors escaping the built-in route table, the feature-state route, and custom-route dispatch are now logged and delivered to your onError hook (and to Sentry, when configured) exactly as consumer-route errors already were.
  • Custom-route dispatch failures (connection-pool acquisition, authentication) used to be swallowed and fall through to static-file handling, answering with the wrong status; they now return 500.

Changed

  • zigbase migrate status now exits 1 when any migration is pending or orphaned, and 0 otherwise, so it can gate a deploy: zigbase migrate status || zigbase migrate. It previously always exited 0. The JSON form carries the same signal as ok.
  • Every CLI usage error (an unrecognized command, an unknown flag, a bad flag value) now exits 1, program-wide. It previously exited 0 after printing the error and usage — silently telling a script or deploy step that a rejected invocation had succeeded.
  • A malformed environment variable now aborts startup with a message naming the variable, the offending value, and the accepted form, instead of dying with a bare parse error or silently falling back to a default.
  • Boolean environment variables accept exactly true, false, 1, or 0. Any other spelling is now a startup error. Previously anything that was not true or 1 silently meant false, so ZIGBASE_TRUST_PROXY=yes quietly left the knob off.
  • An unrecognized ZIGBASE_* variable now logs a startup warning naming it (never its value), so a typo'd knob is visible instead of ignored.
  • The recipes.md testing recipe now teaches zigbase.testing (in-process, StartOptions-based determinism, captureMail). The process-global zigbase.testcapture seam is still documented, under a heading that says it is for a spawned server.

Security

  • Legacy credentials are only ever installable through the offline CLI import: the HTTP path continues to strip passwordHash and verified from every client payload, so no request can install one. The algorithm allowlist is matched against the explicit tag, never inferred from a hash's own prefix, so an untagged foreign hash matches no verifier and fails closed. Nothing ever writes a legacy hash back after an upgrade.
  • Startup provisioning now persists an access-rule change to an existing collection. A rule
    edited in a comptime .collections literal was silently dropped unless the same startup also
    added a field or changed .ttl_field — so a developer who tightened a rule in code and
    redeployed kept enforcing the old, looser rule until the collection was touched some other way
    (access rules are enforced from the persisted _collections row, not from the comptime
    literal). Rule changes are now written on every startup, via a metadata-only update: no table
    rebuild, no row copy, and no risk to indexes the provisioner doesn't manage. A rule left unset
    (null) still means "leave the live value alone", and null vs "" is not treated as a change
    (both mean locked/superusers-only). Note that .indexes changes on an existing collection are
    still not re-applied — see Known limitations.
  • Hardened attacker-facing parsers and auth/WebAuthn owned-result builders against leaks on allocation failures, and made multipart request-arena ownership explicit.
  • Tenant scoping can no longer fail open on an identifier check. tenancy.scopeApplies decided tenant-ownership partly from schema.isValidIdentifier(col.name), so a tenant-owned collection whose name that gate rejects — every _-prefixed system collection, plus any collection with a tenant_field planted by writing _collections directly — reported "tenant scoping does not apply", dropping both the forced per-row check and the bound scope predicate and serving the collection un-scoped across all tenants. Tenant-ownership is now decided by the schema alone and the identifier is escaped via ddl.quoteIdent, so no identifier shape can widen scope; a tenant_field naming no column now fails the query instead of returning every tenant's rows. (Audit finding F18's documented residual, now closed rather than merely unreachable.)

Internal

  • changelog.d/README.md now warns up front that scripts/assemble-changelog.sh is destructive —
    it rewrites CHANGELOG.md and git rms every fragment in the directory, including ones
    belonging to other open PRs — and that it is a release-time tool, not a way to check that your
    own fragment parses. Its name reads like a validator, which is exactly the trap.
  • CLAUDE.md no longer describes the assembled block as being inserted "below ## [Unreleased]". There is deliberately no ## [Unreleased] section in CHANGELOG.md, and the assembler anchors on the most recent released-version heading rather than requiring one — the old wording invited a contributor to re-add a section the project removed on purpose.
  • Added the standard open-source community health files: CONTRIBUTING.md (toolchain setup, the two test suites and why a green zig build test doesn't imply a green browser suite, the NO_SLOP.md quality bar, changelog fragments, docs/examples sync, PR process), CODE_OF_CONDUCT.md, and SECURITY.md (supported versions, private vulnerability reporting, what to include, coordinated-disclosure expectations, and an explicit out-of-scope list covering documented trade-offs like --insecure-cookies, @public rules, and unauthenticated static serving). The code of conduct is the canonical Contributor Covenant 2.1 text verbatim, differing only in the [INSERT CONTACT METHOD] substitution, so GitHub's community profile identifies it as Contributor Covenant rather than "Other"; the routing guidance — conduct concerns go to the enforcement contact, not to a public issue and not to the security advisory form — lives in CONTRIBUTING.md, which already handles the other "never file this publicly" case.
  • CONTRIBUTING.md states the project's position on AI-assisted contributions explicitly: they are welcome, judged on code properties against NO_SLOP.md rather than authorship, with the contributor responsible for every line — a deliberate departure from the upstream Zig project's no-LLM policy, noted so contributors aren't confused about which applies where.
  • Added GitHub issue forms under .github/ISSUE_TEMPLATE/ (bug report, feature request, documentation) plus a config.yml that disables blank issues and links to the docs site, private security reporting, KNOWN_LIMITATIONS.md, and the contributing guide. The bug form requires the version, build flags, and backend, since several subsystems are comptime-gated and absent from a stock build.
  • The four client SDKs' error-parsing fixtures now carry the current {status, code, message, data} envelope. They passed either way — none of the SDKs reads the top-level code — so they were quietly teaching the pre-unification shape to anyone reading them.
  • scripts/check-gating.sh gains a fourth reference binary (the stock zigbase binary rebuilt with -Ddev-tools=false) and two patterns (scaffold., codegen.) proving the gate actually removes the scaffolding/codegen subtree rather than just making it unreachable dead code. CI adds a matching -Ddev-tools=false build + full test-suite run so the stripped configuration can't rot silently.
  • Every SQL identifier the engine interpolates is now escaped through ddl.quoteIdent instead of being wrapped in bare quotes and relying on an upstream charset gate — across ddl.zig (which defined the helper and mostly did not use it), query/joiner.zig, api/auth.zig, api/oauth.zig, rules.zig, realtime/hub.zig, migrator.zig, provision.zig, import.zig, collections.zig, search/vector.zig, and analytics/api.zig. The emitted SQL is byte-identical for every name that can exist, so this is a consistency and defence-in-depth change rather than a behaviour change; composite index/constraint names are assembled and then escaped as one unit so a CREATE and its later DROP agree on the name.
  • The three example apps opt into structured logging (pub const std_options = zigbase.std_options;), so --log-format and --log-level work when running them.
  • gen-server-packages.mjs emits the alias manifest from the same build.zig.zon version as the meta package, so the alias and its @zigbase/server pin cannot drift, and publish.mjs re-checks the pin before publishing. It gains a --what alias scope (no cross-build, publishes only zigbase) which is also how the name gets claimed; release.yml publishes the alias after the meta on a v* tag. New test-alias-install.mjs packs real tarballs and installs them with no registry access, covering the npx path that requires the alias to declare its own bin, argv and exit-code forwarding through both shims, and the duplicate-bin case.
  • New end-to-end suites tests/admin/test_schema_cli.py, tests/admin/test_import_manifest.py, tests/admin/test_legacy_auth.py and tests/tools/test_replay.py; tests/tools runs in the browser CI job.
  • Corrected the documented release process and moved it out of CLAUDE.md into a lazy-loaded releasing skill (.claude/skills/releasing/SKILL.md). CLAUDE.md had described scripts/release.sh [--publish] as the way to cut a release; it is the manual bootstrap/offline/emergency fallback (as the script's own header states) and it publishes neither the Docker image nor the npm packages. The primary path is pushing a v<version> tag, which .github/workflows/release.yml turns into one 4-target build fanned out to the GitHub release (tarballs + SHA256SUMS, body extracted from CHANGELOG.md), the multi-arch ghcr.io/valthon/zigbase image, and the @zigbase/server* npm packages — with every job asserting the tag matches build.zig.zon. The client SDKs each release on their own tag, and docs/security-audit.md's dependency-bump checklist no longer points at the fallback script. All of this is needed only when cutting a release, but as always-loaded memory it cost roughly 890 tokens of context in every session, so CLAUDE.md now keeps just a pointer; the non-release gh pr edit gotcha moved up into "Conventions that bite", where it applies to any PR.
  • changelog.d/README.md no longer claims assemble-changelog.sh writes the published mirror site/src/content/docs/changelog.md — the script stopped touching it (it is a generated build artifact regenerated by site/scripts/gen-docs-mirror.mjs), and the file's intro paragraph had gone on contradicting its own "At release" section a few lines below. It now also notes that the assembled block becomes the GitHub release body, which is why the pre-merge consistency review matters.
  • Added bounded coverage-guided fuzz targets for filter, query-string, PostgreSQL connection-string, and WebAuthn parsers, and corrected optimized-mode configuration in the benchmark harness.
  • Fixed a latent port collision in the live SMTP-over-TLS test fixture (tests/smtp). It chose its SMTP and HTTP ports with two sequential bind(0)-then-close calls, which returns each port to the ephemeral pool before it is used, so both roles could be handed the same number: aiosmtpd bound it, zigbase serve then failed to bind, and the test's HTTP request reached the SMTP listener — surfacing three steps later as BadStatusLine: 220 ... Python SMTP. Both ports are now reserved together, with every socket held open until all are chosen, which removes the duplicate rather than making it rarer. The readiness guard was also complicit: it only opened a TCP connection, so an SMTP listener satisfied "zigbase serve did not come up"; the HTTP role now requires a real HTTP response, failing at the point of failure instead of three steps later.
  • examples/blog gained a zig build test step using zigbase.testing, and its App is hoisted to a pub const so tests can reach it; the step runs in CI. Its vitest e2e stays — it is the only end-to-end coverage of @zigbase/client over a real socket.
  • tests/admin/test_docs_parity.py now fails when a docs/*.md is only half-registered on the site (registry, the mirror generator's PUBLISHED set, site/.gitignore, and the sidebar must agree).