Skip to content

Changelog

All notable changes to iiiris are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning — see docs/specs/releases.md for the release contract (versioning rules, artifact set, cut-release flow).

Unreleased

Added

  • Published documentation site at https://docs.iiiris.org. The docs/ Markdown tree is rendered with MkDocs + Material and deployed to GitLab Pages by a new pages CI job on every default-branch docs change and every release tag. Versioned with mike: dev tracks main, each release vX.Y.Z publishes version X.Y aliased latest. Source docs are unchanged — a build hook imports README/CHANGELOG, injects the logo, and rewrites links. See docs/deployment.md "Documentation site" (includes the one-time DOCS_DEPLOY_TOKEN + DNS setup). Docs-only changes still run no MR pipeline; publishing happens post-merge.

  • Error tracking via Sentry (off by default). iiiris can report errors to a Sentry project through the official Go SDK: set sentry.enabled: true and a sentry.dsn (or IIIRIS_SENTRY_DSN) to capture recovered panics (with stack traces), 5xx responses, and internal source / decode / cache errors (reported at the error site, tagged by route + status), each toggleable via sentry.capture.*. Events are attributed by environment and build release. Privacy-first by defaultsend_default_pii: false strips client IPs, cookies, auth headers, and URL query strings before any event leaves the process (opt in with send_default_pii: true). A single new consumer package (internal/telemetry) owns the SDK; the request path carries no instrumentation when disabled, and events are flushed on graceful shutdown. Configure via the sentry.* block or IIIRIS_SENTRY_* env vars. See docs/configuration.md.

  • Distributed tracing via OpenTelemetry (off by default). With tracing.enabled: true iiiris emits a root span per request plus child spans across source open and cache get/put, exported over OTLP/gRPC to tracing.otlp_endpoint — any collector (Tempo/Jaeger/…) or Sentry's own OTLP ingestion URL. Inbound W3C traceparent is honored (ParentBased head sampling at tracing.sample_rate), and when Sentry error tracking is also on, captured errors are stamped with the active trace ID so an error links to its trace. All OpenTelemetry lives in internal/telemetry (root span via an opaque middleware, source/cache spans via Traced* wrappers), so the server, source, and cache packages stay SDK-free; the request path is untouched when disabled. Optional upstream propagation (tracing.propagate_upstream, off by default) injects the W3C traceparent header on outbound HTTP source fetches so a trace spans iiiris → origin (S3 is out of scope — a terminal service). Configure via the tracing.* block or IIIRIS_TRACING_* env vars. See docs/configuration.md.

  • Content State bookmark store (Form-B hosting) (roadmap Phase 2b, off by default). iiiris can host content states at stable, dereferenceable URIs so a state is shared as a short link rather than a long encoded blob: POST /content-state (gated by a content_state.store.write_token Bearer token; refused when no token is configured) stores a state and returns its URI; GET /content-state/s/{id} serves it as application/ld+json with CORS + ETag. Durable, single-node filesystem store (internal/annostore, non-evicting), shaped so a later W3C annotation store subsumes it. Enable via content_state.store.{enabled,dir,write_token}. See docs/content-state.md.

  • Model Context Protocol (MCP) server — core (roadmap 2.0-era platform glue). iiiris can expose its read-only IIIF capabilities to AI agents over MCP: a Streamable HTTP endpoint at /mcp offering four tools — browse (list a collection), get_image_info (dimensions + capabilities), get_region (render a region, returned as an image the agent can see), and get_manifest (the IIIF Presentation document for an identifier — stored or derived). Any MCP host connects by URL (Claude Desktop/Code, or the Claude API remote-MCP connector). Off by default (mcp.enabled: false) — a new protocol surface and dependency (the official modelcontextprotocol/go-sdk). Tools reuse the existing resolve/probe/render/list and Presentation serve/derive paths, so get_region enforces the same limits as the Image API. MCP enforces the same IIIF Auth — a host presents its access token as a Bearer token (or cookie) on the /mcp connection; gated identifiers are denied without it and browse omits them (no separate MCP credential, no extra config). Manifests are also exposed as MCP resources (a iiiris:///presentation/{identifier} template) for URI-addressable, host-readable access. See docs/mcp.md.

  • IIIF Content State API 1.0 — stateless core (roadmap Phase 2a). iiiris now speaks the shareable "open the viewer exactly here" format:

  • The bundled viewers read the iiif-content parameter. A /view/?iiif-content=<state> link (or /view/{id}?iiif-content=…) opens Mirador or Universal Viewer at the encoded Manifest, Canvas, or Canvas region — Mirador is pointed at the target canvas server-side, UV reads the state natively.
  • GET /content-state/mint builds a content state (plus its encoded value and a ready-to-share /view URL) from one of your Presentation identifiers, with an optional canvas and xywh region.
  • GET/POST /content-state/resolve decodes and inspects a state.
  • New internal/contentstate package: the content-state-encoding codec (round-trippable base64url ∘ encodeURIComponent) plus parse/build/validate.

Stateless and on by default (mirrors Presentation); disable with content_state.enabled: false. Endpoints sit under /content-state/ (outside /iiif/) since the spec defines no server API. The off-by-default bookmark store (Form-B hosting) is a later slice. See docs/content-state.md.

[v0.8.0] - 2026-07-03

Added

  • Metadata privacy — derivatives now strip embedded EXIF, XMP, and IPTC metadata (camera model, serial, GPS coordinates, captions, keywords) by default, so sensitive source metadata no longer ships in served images. Controlled by the new image.metadata config field (IIIRIS_IMAGE_METADATA): strip (default) or preserve. Also trims served bytes.

Changed

  • BREAKING (default behaviour): image.metadata: strip normalizes derivatives. In the default strip mode iiiris now, in addition to removing EXIF/XMP/IPTC:
  • Normalizes colour to sRGB (colour) / sGray (grayscale, not promoted to RGB), converting CMYK, and embeds a ~0.5 KB micro ICC profile — colour renders correctly in every viewer, including wide-gamut/P3 displays.
  • Bakes EXIF orientation into the pixels (autorotate) and reports oriented info.json dimensions, so images display upright regardless of viewer EXIF support.

This changes served pixels and info.json width/height for orientation-tagged and non-sRGB originals, and reverses the previous "raw dimensions are canonical / no autorotate" behaviour. Migration: set image.metadata: preserve to restore byte-for-byte prior behaviour (no stripping, no colour/orientation normalization, raw dimensions). When upgrading with the default, flush the render and info caches so stale pre-normalization entries are not served (see docs/caches.md).

Fixed

  • EXIF-oriented images downscaled inconsistently. A full-region downscale of an EXIF-oriented source used the shrink-on-load thumbnail path, which auto-rotated the result, while full-size renders and info.json reported the raw (un-rotated) frame — so a viewer scaling or tiling an oriented image got mismatched geometry. Orientation is now handled consistently across all decode paths: upright everywhere in image.metadata: strip (the default), raw everywhere in preserve.

[v0.7.0] - 2026-06-23

Added

  • Plugin-driven image overlays / watermarking. A hook may implement the optional Overlayer capability (the js engine via an overlay(id, ctx) handler) to composite an image overlay onto image responses. Placement covers a nine-position anchor grid with inset offsets, fit modes (none/shrink/scale/tile), and opacity. Two insertion points: target: output draws a fixed-size watermark on the final derivative (after rotation + the quality colorspace conversion, so it sits upright and a gray/bitonal request stays gray/bitonal); target: source places it in source space so it scales and rotates with the image. The overlay image is loaded via the configured sources (filesystem/http/s3) and the decision is folded into the render-cache key (append-only — no churn for un-overlaid requests). Errors and unreadable overlay images fail closed (500). Opt-in: a deployment with no overlay-capable hook does no overlay work. Observed via iiiris_overlay_applied_total / iiiris_overlay_composite_seconds. See docs/hooks.md.
  • JavaScript hook engine (hook.type: js), a pure-Go (goja) peer to the Lua engine and the recommended scripting surface. Scripts export a module.exports object with optional resolve / authorize / redirect / describe handlers; authorize receives a {path, headers} context so hooks can decide on cookies / bearer tokens, and describe makes the JS engine the first hook to supply Presentation descriptor overrides. Sandboxed (no require/Node/filesystem/network/ timers; a single log() builtin into the server log), pooled runtimes, atomic mtime hot-reload (js.path), and a per-call js.timeout (default 1s). Config: hook.js.{path,script,watch_interval,timeout}. See docs/hooks.md.
  • Hook.Authorize now receives request headers (via an AuthRequest struct), enabling header/cookie/token-aware authorization hooks.
  • Prometheus metrics exporting (opt-in via metrics.enabled / IIIRIS_METRICS_ENABLED). GET /metrics serves Prometheus exposition format on the main listener, or on a dedicated listener via metrics.addr; optional HTTP Basic auth (metrics.username / metrics.password). Families: HTTP requests/duration/bytes by route class, cache ops (hit/miss/put/delete/error) + entry/byte footprint per cache role, source open counts + latency, pipeline render duration by decode path (thumbnail/reduced/full) + concurrency-slot queue wait, auth decisions by profile, per-source health, build info, and the standard Go/process collectors. In-process counters scraped per replica — no shared backend required; zero-config deployments are unchanged (off by default). See docs/deployment.md "Prometheus metrics".

Deprecated

  • The Lua hook engine (hook.type: lua) is deprecated and will be removed in iiiris 1.0. It still works; configuring it logs a one-line startup warning. Migrate to the JavaScript engine (hook.type: js) — see docs/hooks.md.

[v0.6.0] - 2026-06-10

Fixed

  • GitLab Releases are created reliably, with publicly-downloadable binaries. The release pipeline relied on the release: keyword, which the runner executes via release-cli — absent from our images and runner-version dependent, so v0.5.0's release-publish failed (release-cli: command not found) and no Release object was created. release-publish now creates the Release via the Releases API (scripts/gitlab-release.sh) and uploads the binaries to the generic package registry, linking those instead of members-only job artifacts. Result: release downloads are anonymously downloadable on the public project regardless of CI visibility, while CI logs/artifacts stay members-only. v0.5.0's Release + package were backfilled manually. See docs/specs/releases.md.
  • Admin dashboard "requests served" counter always read 0. The count was bound to a throwaway Server instance built during the two-phase startup wiring (admin handler needs the server; the server needs the admin handler), while requests were served by the second, live instance. The dashboard headline and the /admin/stats/stream requests field now read the live counter, matching the per-route totals. Per-route stats were unaffected.

Added

  • IIIF Presentation API — serve stored manifests/collections (roadmap Phase 1a). New routes GET /iiif/presentation/3/{id} and /iiif/presentation/2/{id} serve stored Presentation documents resolved through the same hook + source path as image identifiers — a manifest is a JSON document in a backing source. The served document's top-level id (id in 3.0, @id in 2.1) is normalised to its canonical iiiris URL; nested values are preserved verbatim. Responses carry ETag / conditional GET → 304, application/ld+json content negotiation, and a dedicated caches.manifest slot. Enabled by default (presentation.enabled: true) as a stateless part of the zero-config path. See docs/presentation.md (guide) and docs/specs/presentation.md (contract).
  • IIIF Presentation API — auto-derive manifests/collections (roadmap Phase 1b). An identifier that resolves to a directory now derives a document on the fly: a Manifest (one canvas per image file, dimensions from a header probe, each canvas painted by an ImageService3 / ImageService2 on this same server, plus a confined-size thumbnail) when the directory holds images, or a Collection (one member per sub-directory, classified Manifest vs Collection one level deep) when it holds sub-directories. Both API versions. Phase 1b derives from filesystem conventions (directory name → label, filenames → canvas labels, listing order → sequence). Non-image files are skipped; an empty directory is 404.
  • IIIF Presentation API — descriptor layering for derivation. Derived manifests/collections now layer overrides over the conventions: a manifest.{yaml,yml,json} sidecar in the directory (label, metadata block, per-canvas label overrides), then a hook-supplied descriptor (authoritative — wins over sidecar and conventions) for hooks that implement the optional presentation.Describer capability. Malformed overrides are logged and ignored — derivation never fails because an override is bad. See docs/hooks.md.
  • IIIF Presentation API — bundled viewer at /view/{id}. A per-manifest viewer route renders a IIIF viewer pointed at the manifest for that identifier. Mirador is the default and is self-hosted — embedded via go:embed, served at /static/viewer/, referenced root-relative so it loads over the page's own scheme — so rendering works fully offline with no CDN dependency. Universal Viewer is available with ?viewer=uv, loaded from a version-pinned CDN (UV 4.x's ~10–21 MB runtime asset tree is too large to embed); presentation.default_viewer sets the default. Completes the zero-config "folder → manifest → viewer" path. Verified two ways: a Go integration test asserts the self-hosted asset serves and the default page references no CDN (Tier 1), and a build-tagged headless-Chrome test (viewer_e2e, wired as the iiif-viewer-e2e CI stage) drives a real browser and confirms Mirador renders by the IIIF traffic it generates with no JS exceptions (Tier 2).
  • Mixed content: the manifest + image-service URLs use the request scheme, so a TLS-terminating proxy must send X-Forwarded-Proto: https.
  • IIIF Presentation API — structural conformance validation. presentation.Validate checks a manifest/collection against the required structure of its version (3.0 + 2.1): @context, id/@id, type discriminators, required label, and — for manifests — positive canvas dimensions and the annotation/image nesting. Every document iiiris derives is asserted valid by the test suite (a conformance gate that rides the normal go test run). An opt-in serve-time mode (presentation.validate: true) logs structural problems on served/derived documents at WARN without rejecting them (serve-as-is; operators own correctness).
  • IIIF Presentation API — CI conformance harness. A project-local harness (tools/iiif-presentation-conformance) drives a live iiirisd and asserts derived manifests/collections (v3 + v2) are structurally conformant and that each canvas paints an Image API service on the same server. Wired as the iiif-presentation-validate CI stage, mirroring iiif-auth-validate.

Changed

  • docker compose now demos both the Image and Presentation APIs. The stack bind-mounts a new ./examples/images corpus (a root thumbnail.jp2 plus a library/ of object sub-directories — manuscript/ with a manifest.yaml sidecar, plates/) as the IIIF source root, so docker compose up --build exercises Image API derivatives, an auto-derived collection + manifests, and the bundled viewer out of the box. The compose header, README quick-start, docs/deployment.md, and .env.example list the URLs for both APIs. Override IIIRIS_LOCAL_IMAGES to serve your own directory as before.
  • docker compose Redis caching example. docker-compose.redis.yml is an opt-in overlay that adds a redis service (bounded 256mb + allkeys-lru) and routes the render / info / manifest caches through it via examples/config/redis-cache.yaml. The base stack stays heap-cached; docker compose -f docker-compose.yml -f docker-compose.redis.yml up enables it. See docs/deployment.md.

[v0.5.0] - 2026-06-07

Changed

  • JPEG output is now baseline, not progressive. iiiris was emitting progressive (interlaced) JPEG by accident — govips' export default — which does multiple entropy-coding passes and is ~3× slower to encode than baseline (measured on a 1024 px tile), for ~10% smaller files. Baseline is the right trade for per-request tile/derivative serving and matches conventional IIIF servers. This roughly triples JPEG encode throughput on render-bound workloads. Operators who want progressive (incremental browser rendering, smaller files) set the new image.jpeg_progressive: true. See docs/configuration.md.
  • Resolution-aware decode. A request that downsizes now decodes only the resolution the output needs — JPEG DCT scaling, JP2 / pyramidal-TIFF reduction levels — instead of decoding the full source then resizing. This covers both full-region downscales (thumbnails / scaled derivatives) and arbitrary-region downscales (deep-zoom tiles, where the crop is remapped onto the reduced grid). Large masters render far faster and with far less memory; output is equivalent within resampling tolerance, and any source whose codec doesn't cleanly reduce falls back to the previous full-decode path. See docs/briefs/RESOLUTION_AWARE_DECODE.md.
  • image.max_concurrent now defaults to the host CPU count (was unbounded). Renders are CPU-bound, so matching the core count uses the whole machine while bounding peak memory under a request flood (each in-flight decode holds its working set). Set max_concurrent: 0 explicitly to restore unbounded behaviour, or raise it above the core count for I/O-bound sources where cores would otherwise idle. Zero-config startup is unaffected.
  • info.json reads dimensions from the header, not a full decoder. Building an info.json only needs the source's width and height; iiiris now parses those straight from the file header in pure Go (image.DecodeConfig for JPEG/PNG/GIF, an IFD walk for TIFF, the ihdr box / SIZ marker for JP2/HTJ2K) instead of constructing a libvips image to read two integers — which was genuinely expensive for JP2 (OpenJPEG header parsing). Unrecognised formats or unparseable headers fall back to the previous libvips probe, and the parsed dimensions are asserted to match libvips exactly (iiiris applies no EXIF autorotate, so raw header dimensions are canonical). Combined with the mmap change, a dimension probe now touches only the header pages.
  • Heap cache LRU is now O(1) per operation. The in-memory cache tracked recency with a slice that was linear-scanned and re-sliced on every hit under the global lock; it now uses a doubly-linked list with per-entry element pointers, so touch/evict are constant-time and a cache hit holds the lock for O(1) regardless of how many entries are cached. Same LRU semantics; behaviour is unchanged.
  • Filesystem sources are memory-mapped, not fully buffered. Image renders and info.json probes over a filesystem source now mmap the file and let libvips fault in only the bytes it reads, instead of io.ReadAll-ing the whole compressed file into the heap. For a large tiled master a one-tile request (or a dimension probe) no longer allocates the entire file — resident memory becomes independent of source size (measured ~175 MB → ~41 MB on a 134 MB tiled TIFF) and the per-request allocation/GC cost drops. Network sources (HTTP/S3), the origin-cache wrapper, raw J2K codestreams, and non-Unix platforms keep the previous io.ReadAll path, so behaviour is unchanged where mmap doesn't apply. See docs/briefs/PARTIAL_SOURCE_IO.md.

Fixed

  • info.json source probes are now concurrency-bounded. A burst of caches-off info.json requests previously had no in-flight limit on the source probe, so each request could hold a whole-source buffer in memory at once — enough to OOM the server under load (the render path was already bounded by image.max_concurrent, but the probe path was not). The probe now acquires a dedicated semaphore sized to the same image.max_concurrent value, bounding the number of in-flight source buffers. No new config; renders and info probes use separate gates so neither starves the other.

Added

  • libvips memory/concurrency limits. Two new image config fields — vips_concurrency (worker threads per libvips operation) and vips_max_cache_mem (operation-cache cap, bytes) — let operators tune or bound libvips memory, which previously wasn't possible (iiiris started libvips with no configurable limits). Both default to 0 = the libvips default (concurrency 1, 50 MiB cache), so existing behaviour is unchanged. Env overrides: IIIRIS_VIPS_CONCURRENCY, IIIRIS_VIPS_MAX_CACHE_MEM. See docs/configuration.md.

[v0.3.6] - 2026-06-04

Added

  • none cache backend. Any cache slot (render, info, origin) accepts backend: none, which disables it — every lookup misses and every store is discarded. Lets a deployment turn a tier fully off without a nil-cache code path; used by the new comparative benchmark harness to isolate the render pipeline from caching effects. See docs/caches.md.

Internal

  • Comparative benchmark harness (tools/bench-compare + bench/). Drives the same IIIF workload (info / full-scaled / tiled across JPEG, HTJ2K, and a large master) against iiiris and Cantaloupe one at a time under identical Docker resource limits and matched output, sampling throughput, latency percentiles, and CPU/RSS for caches-off and caches-on passes, and emitting a Markdown + JSON comparison. Local for tuning, Terraform-provisioned AWS host for citable numbers; deliberately not wired into CI. Design: docs/briefs/CANTALOUPE_BENCHMARKS.md.
  • release-image no longer collides on the macOS runner keychain. docker login on a Docker Desktop host force-enables the desktop/osxkeychain credential helper even with an empty per-job DOCKER_CONFIG, so the two concurrent release-image jobs both wrote the registry credential to the host-global login keychain and raced — one won, the other failed with errSecDuplicateItem (-25299) (v0.3.5's release-image). The base job now skips docker login entirely and writes the auth directly into the ephemeral per-job config.json; buildx --push authenticates from there and never touches the keychain (also avoiding the UI-coupled helper's hang on public pulls).
  • Conformance jobs no longer race for a shared port. iiif-validate and iiif-auth-validate run concurrently in the validate stage and both bound iiirisd to :18180 under --network host, so whichever lost the bind race silently probed the other job's (auth-less) server — surfacing as 8/8 spurious auth-conformance failures with empty service[] and 404s on every auth route. iiif-auth-validate now owns :18280; both jobs gained a pre-flight port check and a bind-failure (kill -0) guard so any future collision fails loudly instead of validating the wrong process.

[v0.3.4] - 2026-06-01

Fixed

  • release-publish uses the modern glab image. The old registry.gitlab.com/gitlab-org/release-cli:latest no longer contains the release-cli binary it used to (deprecated and removed upstream); the wrapper image's entrypoint still tries to invoke it and fails with release-cli: command not found. Switched to registry.gitlab.com/gitlab-org/cli:v1.100.0, the glab CLI image GitLab now recommends for the release: keyword. v0.3.3's release pipeline hit this after the credential-helper hang was unblocked.
  • release-image jobs no longer hang on the Docker credential helper. .release-image-base now isolates docker auth into a per-job DOCKER_CONFIG directory instead of using the runner host's ~/.docker/config.json. On macOS Docker Desktop the shared config defaults to credsStore: desktop, which talks to a UI-coupled helper that hangs indefinitely when called from launchd-managed gitlab-runner (no UI session) — even for public pulls like tonistiigi/binfmt. v0.3.3's first release pipeline hit exactly this and the release-image + release-image-debian jobs timed out at 60 minutes. The per-job config has no credsStore, so docker writes plain auths entries that work without UI coupling. Also retires the docker logout keychain workaround since the system keychain isn't touched at all.
  • release-binary-linux-amd64 no longer silently builds a misnamed binary. The job's docker run didn't pass -e CI_COMMIT_TAG, and the inner bash -c '...' uses single quotes — so ${CI_COMMIT_TAG} inside the container expanded to an empty string. The go build succeeded but wrote iiirisd--linux-amd64 (empty version slot); the host-side sha256sum "iiirisd-vX.Y.Z-linux-amd64" then looked for a file that didn't exist and failed. v0.3.2's release pipeline broke on this. Now passes -e CI_COMMIT_TAG through and uses set -eu inside so an unset variable fails loud.
  • release-binary-smoke now mirrors the release job's shell-expansion + filename path. Smoke writes its output to the same iiirisd-${CI_COMMIT_TAG}-linux-amd64 template (falling back to v0.0.0-smoke on non-tag pipelines) and runs sha256sum against that name. Future env-passthrough regressions of the v0.3.2 shape will fire on push instead of at tag time.
  • release-binary-linux-amd64 actually produces a binary on apple-silicon runners. The job was running an arm64 container and invoking x86_64-linux-gnu-gcc to cross-compile a CGO binary for linux/amd64, but the cross-compiler wasn't installed (the job's runtime apt-get install was a no-op since the container runs as the unprivileged runner user), and even if it had been, the linker would have failed because the image only carries arm64 libvips/glib/openjpeg shared objects. The job now runs the build inside a --platform linux/amd64 container so the toolchain, libvips, and binary are all natively amd64 — the same Rosetta/binfmt path docker-build-distroless and release-image* already use for buildx. Confirmed v0.3.1's release pipeline died on this; the fix unblocks future tag pushes.
  • docker login no longer fails on macOS-runner re-runs. Docker Desktop's default credential helper writes to the system keychain, and a prior login left an entry the next login couldn't overwrite (errSecDuplicateItem -25299). The shared before_script in .release-image-base now runs docker logout "$CI_REGISTRY" || true first — idempotent, no-op on linux runners with file-store credentials.

Internal

  • release-binary-smoke job runs on every pipeline. Same docker invocation as release-binary-linux-amd64, building to /tmp inside the container. Catches toolchain regressions (missing cross-compile path, missing CGO deps, missing --platform flag) on push instead of at tag time — important because the spec's forward-only rollback means a bad tag is a tombstone.
  • CI builder image split into host-arch + amd64 variants. iiiris-ci:latest stays host-arch for jobs that build and run iiirisd (lint, test, bench, iiif-validate, iiif-auth-validate); govips's CGO bindings into libvips segfault under Rosetta, so these jobs need native arch. New iiiris-ci-amd64:latest, built via --platform linux/amd64, is used only by release-binary- jobs which cross-build a linux/amd64 binary that leaves the container as an artifact. The amd64 image is never used to run* iiirisd, side-stepping the Rosetta+CGO crash.

[v0.3.1] - 2026-05-27

Fixed

  • Tag pipelines without RUN_WINDOWS=1 no longer fail validation. Previously, release-binary-windows-amd64 and release-binary-windows-msi declared needs: on the gated build-windows-* jobs without marking them optional, so GitLab rejected the entire pipeline at creation time when RUN_WINDOWS was unset. The two re-export jobs now also gate on RUN_WINDOWS=1 (skipping entirely when absent), and release-publish marks its needs on them optional: true so it remains valid in their absence. Windows asset links in the GitLab Release entry are still hardcoded — they resolve to 404 on non-Windows releases. Per docs/specs/releases.md "Windows artifact gating" the dead-links wart is retired when docs/briefs/WINDOWS_EC2_RUNNER.md lands.

Internal

  • CI pipeline parallelism + interruptibility. Non-release jobs (lint, test, bench-smoke, docker-build, docker-build-distroless, iiif-validate, iiif-auth-validate) now declare needs: [ci-image] so they start as soon as the CI builder image is ready instead of waiting for stage boundaries — combined with the runner's bumped concurrency they run in parallel rather than serializing. default: interruptible: true cancels in-flight jobs from a superseded pipeline when a newer commit lands on the same branch. release-publish declares its dependencies with artifacts: false, skipping the ~280-390 MB download of release binaries/zip/MSI it never reads (the release: block links to them by URL).

[v0.3.0] - 2026-05-27

Licensing

  • Relicensed from MIT to the Apache License 2.0. Previously released versions (≤ 0.2.0) stay under MIT; the relicense applies from this point forward. Apache-2.0 adds an explicit patent grant (with patent- retaliation clause), explicit NOTICE-preservation obligations for upstream Apache-2.0 dependencies, and matches the IIIF ecosystem's predominant outbound license (the spec itself, IIIF-Java, IIIF Presentation libraries, Mirador) as well as the licenses of the largest dependencies (AWS SDK, go-oidc, go-jose). Compatible with every direct and indirect dep (MIT / BSD-2 / BSD-3 / Apache-2.0) and with the LGPL-2.1-or-later runtime dependency on libvips. New LICENSE (verbatim canonical Apache-2.0 text), NOTICE (project copyright + reproduced upstream NOTICE blocks for the AWS SDK, smithy-go, and go-oidc — Apache-2.0 §4 requires keeping these intact), and THIRD_PARTY_LICENSES.md (full per-module inventory covering Go deps, the vendored OpenSeadragon, and the linked system libraries). README gains a License section.
  • Release-image compliance bundle. Both Dockerfiles (deploy/Dockerfile and deploy/distroless/Dockerfile) now COPY the LICENSE, NOTICE, and THIRD_PARTY_LICENSES.md into the image at /usr/share/doc/iiiris/; the distroless variant additionally bundles a new LIBVIPS-SOURCE.md — the LGPL-2.1 §6(d) "info to relink" pointer documenting pinned libvips / OpenJPEG / libtiff versions, source URLs, and build flags. The stale org.opencontainers.image.licenses="MIT" OCI labels on both images are corrected to "Apache-2.0". A deployed binary now points at its own license terms and (for the static distroless build) its own relink recipe — closing the LGPL §6 loop at runtime as well as in the source tree.

Changed

  • filesystem cache backend: concurrent-process safety. Replaces the single-process-only implementation. Multiple iiirisd processes on the same host may now share one cache directory; eviction is serialized across processes via a flock on <path>/.lock. Get and Put remain lock-free; only the eviction critical section takes the lock. Cap enforcement is best-effort across processes (typical overshoot <1% of cap). On first attach to a directory containing pre-FS_CACHE2 cache files, the legacy entries are wiped and a .iiiris-cache-version sentinel is written; operator-dropped files are preserved.
  • GitLab Release description sourced from CHANGELOG.md. The release-publish CI job now extracts the body of the ## [vX.Y.Z] section from CHANGELOG.md (heading stripped, leading/trailing blank lines trimmed) and writes it to RELEASE_NOTES.md, which the release: block uses as the GitLab Release description. The tag annotation message (git tag -s -m "...") is prepended as a one-line supplemental summary. CHANGELOG.md is now the single source of truth for release content — no more duplicating between the tag annotation and the CHANGELOG section. Contract owned by docs/specs/releases.md "Release notes".
  • scripts/cut-release.sh automates the pre-tag steps. Run scripts/cut-release.sh vX.Y.Z to validate state (version format, clean tree, on main, tag doesn't already exist, [Unreleased] non-empty), flip CHANGELOG.md [Unreleased][vX.Y.Z] - YYYY-MM-DD, commit on release/vX.Y.Z, and push the branch. The maintainer opens the MR, merges, then signs and pushes the tag themselves (the tag is the explicit "ship this" gesture). A companion project skill at .claude/skills/cut-release/ walks maintainers through the same flow from Claude Code.
  • SHA256 checksums for every binary release artifact. The Linux amd64 binary, Windows amd64 zip, and Windows amd64 MSI each ship with a .sha256 file alongside, attached to the GitLab Release. Verify with sha256sum -c iiirisd-vX.Y.Z-<flavor>.sha256 (Linux) or shasum -a 256 -c (macOS) from the directory containing both files.

Added

  • filesystem cache backend: distributed mode (Redis coordinator). Multi-replica iiirisd deployments sharing an NFS/EFS mount can now use the filesystem backend by adding a coordinator.redis block alongside backend: filesystem. The files live on disk as in local mode; the index (entries → sizes, LRU order, total bytes) moves to Redis where it stays consistent across replicas. Eviction is serialized by a TTL'd Redis lease; startup runs a reconciliation pass under the lease to align the index with disk state. The coordinator.redis block uses the same shape as the existing redis: configs; backend: redis and coordinator.redis are mutually exclusive. See docs/caches.md § Distributed mode (Redis coordinator).

Added

  • Project logo across the server, admin UI, and docs. Vector wordmark + tile-pyramid mark in logos/ (light, dark, square icon, 32 px favicon — all SVG). README + every docs/**/*.md open with the mark above the first heading. The admin UI sidebar now leads with the icon mark next to "iiiris admin", and the layout serves /admin/static/favicon.svg so tabs get the iiiris mark. New public-facing routes on the IIIF server itself: GET / returns a minimal landing page rendering the wordmark (auto-dark via prefers-color-scheme), with the SVG sources served at GET /logo.svg and GET /favicon.svg. All assets are embedded into the binary — no extra files on disk to manage.
  • Windows MSI installer service-install mode. The Windows MSI (Phase 3b) now optionally registers iiirisd as a Windows service that starts on boot. Enabled via msiexec /i ... INSTALLSERVICE=1; an attended GUI checkbox is a future polish. The service runs as NT AUTHORITY\LocalSystem with Start=auto; operators can swap to NetworkService or a domain account via services.msc. Windows Event Log integration ships in lockstep — the installer registers the iiiris Event Log source under HKLM so structured logs appear under Event Viewer → Applications and Services Logs → iiiris, alongside the rolling file at %ProgramData%\iiiris\logs\iiirisd.log. Per-user installs cannot register HKLM keys, so service mode is auto-disabled there (the brief's "disable + explain in dialog" decision; the feature simply doesn't apply rather than producing a cryptic install error). Upgrade flow handles the binary-replace dance: ServiceControl Stop="both" stops the old service before MajorUpgrade writes the new binary, then Start="install" starts it. Uninstall removes the service, deregisters the Event Log source, and preserves %ProgramData%\iiiris\{config,cache, logs}\ per the Permanent="yes" contract. Start Menu shortcuts land alongside (console-launch hidden in service mode; admin URL + uninstall always present). The MSI is self-signed in CI (SIGN_MSI=1 gates the sign step against a PFX in CI secrets); SmartScreen shows "Unknown publisher" on first run. A future EV/OV cert procurement replaces the PFX without code change. See docs/specs/windows-build.md for the full contract and docs/deployment.md for operator walkthrough.
  • Windows MSI installer (binary-install mode). Tagged releases now publish iiirisd-vX.Y.Z-windows-amd64.msi alongside the zip. Standard Windows wizard with per-user (%LocalAppData%\iiiris) or all-users (Program Files\iiiris) layout choice via WiX v5's WixUI_Advanced dialog. First install drops a commented config.yaml.example at %ProgramData%\iiiris\config\config.yaml; operator edits survive upgrades. MajorUpgrade stops the prior install (if any), replaces the binary, and restarts; data dirs (config, cache, logs) are marked Permanent="yes" and preserved on uninstall — manual wipe of %ProgramData%\iiiris is the documented clean-slate path. MSI is self-signed so SmartScreen shows "Unknown publisher" on first run; the CI wiring is stubbed for a future EV/OV cert flip. New deploy/installer/iiiris.wxs (hand- authored Package + Feature + standard directories + components), generated deploy/installer/dlls.wxs (the 91-DLL inventory; freshness enforced at build time by scripts/wix-generate-dlls.ps1 -Mode Check), and CI jobs build-windows-msi + tag-only release-binary-windows-msi complete the pipeline. Optional Windows-service-install mode (ServiceInstall + Event Log source registration) is the remaining Phase 3c work; for now, manual sc.exe create iiiris ... registers the installed binary as a service per docs/deployment.md "Running as a Windows service (manual)".
  • Windows service-mode entrypoint and Event Log integration. iiirisd.exe now detects Windows Service Control Manager context automatically (via golang.org/x/sys/windows/svc.IsWindowsService) and behaves as an SCM-managed service when launched by it, otherwise running as a console process. The service path translates SCM Stop / Shutdown control events into the same graceful http.Server.Shutdown flow the console path triggers on Ctrl+C, with the configured server.shutdown_timeout drain budget. Two new slog.Handlers ship for service-mode logging — a Windows Event Log writer (Phase 3c's WiX installer will register the iiiris source under HKLM; until then the handler returns nil at startup and falls back to file + stderr) and a size-based rolling log file via lumberjack.v2 (100 MB per file, keep 5). The rolling file is also available in console mode via the new log.file config field (IIIRIS_LOG_FILE). Manual service registration via sc.exe create iiiris binPath=... works today; the full WiX MSI installer with optional service-install mode lands in vX.Y+1. See docs/deployment.md "Running as a Windows service (manual)".
  • Published Windows/amd64 binary. Tagged releases now publish iiirisd-vX.Y.Z-windows-amd64.zip alongside the existing distroless + debian-slim container images and the linux/amd64 binary. The zip contains iiirisd.exe plus its MSYS2 UCRT64 runtime DLLs (libvips, libjpeg, libtiff, libwebp, libpng, libopenjp2, gcc runtime + transitive deps — ~90 DLLs total) and the compliance bundle (LICENSE, NOTICE, THIRD_PARTY_LICENSES.md). Extract anywhere and run the .exe directly; no MSYS2 install on the target host. Linkage is dynamic, so LGPL §6(d) is satisfied trivially by drop-in replacement of the libvips DLL. New build-windows-amd64 CI job on the GitLab SaaS Windows runner builds the binary with CGO via MSYS2 UCRT64; a new scripts/bundle-windows.ps1 resolves the DLL closure and assembles the zip; per-format smoke (jpg / png / tif / webp) runs against the staged binary with MSYS2 stripped from PATH so a missing bundled DLL surfaces as a startup failure rather than silently falling back to the build-host install. A new tag-only release-binary-windows-amd64 re-exports the zip with expire_in: never for the GitLab Release asset link.
  • Windows portability fixes (toward a published Windows build). cmd/iiirisd now selects shutdown signals via build-tagged helpers (SIGINT + SIGTERM on Unix; os.Interrupt on Windows — SIGTERM does not exist on Windows). internal/source/filesystem.go's safePath rejects NUL bytes, any : (catches Windows drive-letter injection and NTFS alternate data streams), and reserved DOS device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) in any path component — rejected universally so Linux-authored collections stay portable to Windows. internal/cache/filesystem.go notes the Windows rename sharing-violation edge for Put; the actual retry wrapper lands when needed. No behaviour change on Linux/macOS.
  • Per-IP concurrency cap. New image.max_concurrent_per_ip caps how many IIIF requests a single client IP may have in flight at once — the per-client analogue of the global image.max_concurrent guard, so one client can't occupy every render slot. It is a concurrency cap, not a rate limiter (steady traffic is never rejected); over-cap requests get 429 Too Many Requests. Applies to /iiif/... only — /health, /ready, and /admin are never throttled. Ships disabled (0): unlike the global guard, a per-IP cap can reject legitimate traffic when users share one egress IP (a NAT'd reading room), so it is opt-in, and behind a proxy it requires auth.trusted_proxies so the real client IP is the key. General rate limiting / DDoS defence stays at the edge (reverse proxy / CDN) by design — see docs/deployment.md. The limiter middleware is only wired into the chain when the cap is non-zero, so a disabled cap costs nothing. (auth.parseCIDRs exported as auth.ParseCIDRs to back the trusted-proxy parse.)
  • Lua hook hot-reload. Path-mode Lua scripts (hook.lua.path) are now live-reloaded: the file's mtime is re-checked at most once per hook.lua.watch_interval (default 5s), and a changed file is recompiled and the script generation is swapped atomically. In- flight calls finish on the previous version, subsequent calls use the new one. Recompile failures (syntax errors, sandbox-disallowed calls, init failures) are logged at WARN and the previous version stays in service — a bad edit cannot break live traffic. Inline scripts (hook.lua.script) are baked into config and never reloaded. Set watch_interval to a negative duration to disable hot reload entirely.
  • IIIF Authorization Flow API 2.0 — all four services (AuthProbeService2, AuthAccessService2, AuthAccessTokenService2, AuthLogoutService2) mounted under /iiif/auth/... and advertised inside info.json for gated identifiers (v2 and v3). The four interaction patterns (clickthrough, active, kiosk, external) are all implemented end-to-end, with four pluggable access-service backends (builtin with bcrypt/apr1 htpasswd, reverse-proxy header trust, signed-callback external, first-class oidc). See docs/iiif-auth.md and the docs/specs/auth.md spec.
  • Hook.Authorize is now wired through to the request path via RuleAuthorizer. Hook decisions can refine a rule-engine verdict (substitute identifier, redirect, profile override) but cannot lift a rule-driven deny to an allow — preventing a default-allow hook from unintentionally opening an auth gate set in YAML.
  • Hook.Redirect is now wired and consulted at the top of every image and info.json request, before auth and parameter parsing. A non-empty URL produces an immediate 303; hook errors surface as 500. Use case: content-relocation (this identifier has moved), independent of who is asking. Distinct from the auth flow's own Decision.Redirect (which runs after auth denies, e.g. to bounce a denied user to an external SSO).
  • Tier-keyed render and info caches. When auth: is configured, RenderCache and InfoCache keys are salted with one of three tiers — public, full, substitute — so a denied-substitute response can't collide with an authorized-full response (or vice versa). The public tier preserves the pre-auth key shape, so introducing auth to a deployment doesn't churn unrelated cache entries. Tier vocabulary is capped at three values; expansion is gated on a cacheKeyVersion bump that forces clean eviction.
  • Per-profile substitute behavior. substitute.max_size (an IIIF size string such as !400,400) degrades the response on deny in place; Hook.Authorize can additionally inject a per-request alternate identifier.
  • access_service.template override for the built-in access service. Operators supply a Go html/template file path; the file is parsed at startup (build error if missing/unparseable) and executed in place of the default login page. Restart required after edits. Template data contract documented in docs/iiif-auth.md.
  • IIIF Authentication API 1.0 dual-advertisement. info.json now carries both the 2.0 service tree and a 1.0 access service block side-by-side (modern viewers follow 2.0; older 1.0-only viewers still find a working path). The smaller 1.0 surface (/iiif/auth/v1/login/{profile}, /iiif/auth/v1/token, /iiif/auth/v1/logout) is mounted alongside the 2.0 routes. The login and logout handlers are reused verbatim — the cookie they manage is identical between versions; only the advertised service tree and the token-response shape (no @context / type, scalar error field on failure) differ. Header-backend profiles continue to advertise the 2.0 probe service only — 1.0 has no probe-only mode, so a 1.0 advertisement without an interactive access service would be ambiguous and is omitted.
  • Real-IP extraction (internal/auth/realip.go) with trusted-proxy CIDR allowlist (auth.trusted_proxies), used by the kiosk-pattern IP allowlist matcher.
  • Sample auth configs under docs/examples/: one self-contained YAML per pattern × backend combination (clickthrough, active with htpasswd file, active with inline users, kiosk, header-trust, external HMAC callback, OIDC). Each is heavily commented for copy-and-modify; the index docs/examples/README.md is a pick-list by pattern and by deployment shape.
  • IIIF Auth 2.0 conformance harness + CI stage. New tools/iiif-auth-conformance Go binary drives the spec-defined Auth 2.0 endpoints (probe, access service, access-token, logout) and asserts the response shapes (@context, type, status, payload fields per §3–§7 of the spec). The harness is -pattern-aware: runs nine checks for each interactive pattern (clickthrough, active, kiosk — they share the spec sequence, differing only in login mechanism) and a shorter four-check sequence for the header backend (no cookie flow, probe-only advertisement). external + oidc backends are out of scope (need a real upstream).

The CI job iiif-auth-validate builds iiirisd, starts it with a multi-profile sample config that defines one profile per pattern, runs the harness once per pattern, and captures the combined iiif-auth-validation.log as a 1-week artifact. The stage is load-bearing — a regression in any of the four patterns fails the pipeline. When a community-blessed Auth 2.0 validator emerges, swap the binary out of the CI job; posture stays the same. - /admin/auth operator page surfaces the IIIF Auth subsystem read-only: configured profiles + rules tables, active session count with per-profile breakdown (when the session backend supports it — heap and filesystem do; s3 reports the total only), and a bounded ring buffer of the most-recent 256 allow/deny decisions (latest-first, with a 24h tally). Nav-bar link added. - Live admin dashboard. The status dashboard updates in place via a Server-Sent Events stream (GET /admin/stats/stream) instead of manual refresh: uptime, request count, cache stats, and per-route metrics refresh once per second; source health (a real network probe on S3) every tenth tick (~10s). Each route row gains a client-side p95 sparkline over the last 60 streamed samples, and a live/reconnecting indicator tracks the EventSource connection. The stream path is excluded from request counting and per-route stats (classifyRoute returns "") so its long-lived connection doesn't pollute the admin route's latency quantiles; statusWriter grew a Flush pass-through so streaming works through the stats middleware. The admin UI is also restyled — sidebar navigation, a single embedded stylesheet (internal/admin/static/style.css) replacing per-template inline CSS, and status badges across the source / cache / auth tables. - Admin config editor. /admin/config gains a structured form over a curated subset of fields (server address + timeouts, log level/format, image pipeline limits). A save validates every field, writes the whole config back to the -config file atomically (temp file + rename, as normalized YAML — fields outside the subset are preserved verbatim), and hot-applies the three fields that can change without a restart: log.level (via a slog.LevelVar), image.max_pixel_area (the source-area guard) and image.max_concurrent (the pipeline's concurrency semaphore, now resizable in place). The image output caps and tile_size stay restart-only on purpose — each is paired with an info.json advertisement, and hot-reloading one side would make info.json lie; a save reports which changed fields need a restart. The editor is read-only without -config (no file to persist to), and individual fields lock when shadowed by an env var. New config.LoadFileOnly (file overlay without env overrides) backs the round-trip; observability.NewLogger now returns its *slog.LevelVar. - auth.DecisionLog — bounded ring buffer (256 most-recent events) of authorization decisions. RuleAuthorizer.Recorder is the hook; cmd/iiirisd/main.go wires it whenever the auth subsystem is enabled. - Optional session.Counter capability (mirrors source.AsLister's discovery pattern). Implemented by HeapStore and FilesystemStore with full per-profile breakdown; S3Store implements it via ListObjectsV2 totals only — a per-profile count would require one GetObject per session. - Four session-store backends. - heap (default): in-memory; sessions vanish on restart. - filesystem (auth.session.backend: filesystem, path: /var/lib/iiiris/sessions): JSON files sharded by SHA-256 prefix; atomic writes; lazy + Sweep-driven expiry; sessions survive restart. - s3 (auth.session.backend: s3, s3.bucket: ..., s3.prefix: ...): JSON objects under <prefix>/sessions/ and <prefix>/tokens/ keyed by sha256. Long-term cleanup is the operator's job via S3 lifecycle policy (Sweep is a no-op, matching the cache backend's posture); expired records are still dropped on read by GetSession/GetToken. Cascade on DeleteSession lists the tokens prefix and removes any token whose SessionID matches. - redis (auth.session.backend: redis, redis.addr: ...): JSON-encoded records under a configurable prefix with native Redis TTL eviction. Bound tokens tracked in a Redis SET per session so DeleteSession cascades atomically in one round trip (SMEMBERS + pipelined DEL) instead of the linear scan the filesystem and S3 backends use. First backend that supports multi-replica iiirisd — Redis's single-key atomic operations prevent the CreateToken / DeleteSession race that the other persistent backends can't avoid.

Opaque 32-byte / base64url tokens minted via crypto/rand. - Redis cache backend for RenderCache, InfoCache, and OriginCache (caches.<slot>.backend: redis, redis.addr: ...). Eviction is the operator's job via Redis MAXMEMORY + an eviction policy (allkeys-lru typical); optional ttl: attaches a per-entry TTL via SET EX. Binary-safe value encoding (2-byte content-type length prefix + content-type + body) so image bytes round-trip exactly without base64 / JSON overhead. Stats reports entry count via prefix SCAN; byte total is not tracked. PurgeAll walks the prefix with SCAN + DEL in batches. Session cookies use Secure + HttpOnly + SameSite=None; the cookie Path is auto-derived from X-Forwarded-Prefix when iiiris sits behind a sub-path proxy.

Documentation

  • README.md and docs/deployment.md Releases section advertise the new Windows/amd64 zip alongside the existing Linux binary and container images. The Windows (local build) subsection now points readers at the published zip as an alternative to building from source; "What's not yet shipped" retires the Windows-binary line (it shipped) and instead names the remaining gap (the WiX MSI installer with optional service registration, planned for a later phase).
  • docs/deployment.md: new "Windows (local build)" subsection covering the MSYS2 UCRT64 install path (choco install msys2, choco install golang, the pacman package set for libvips + gcc + pkg-config + openjpeg2), the UCRT64-shell prerequisite, make build, and the dynamic-CGO linkage with ~90 MSYS2 DLLs. Pinned versions validated by a Phase 1 spike on the GitLab SaaS Windows runner: Go 1.25.0, gcc 16.1.0, libvips 8.18.2. The two previous "cross-compiling CGO + libvips is impractical, no Windows artifact" sentences are retired in favour of a documented local-build path; a published Windows-amd64 zip remains planned for a subsequent release. See docs/specs/windows-build.md for the full three-phase plan.
  • docs/caches.md: new "Windows path-length" note under the filesystem cache backend — the sharded layout adds ~70 characters to path:, so anchoring path: near the volume root keeps the cache well under the 260-character MAX_PATH ceiling without any long-path-support flags.
  • New docs/iiif-auth.md covering the four services, four patterns, four access-service backends, YAML schema, validator posture, and migration notes from Auth 1.0.
  • docs/architecture.md: request flow now shows the auth.Authorizer.Authorize step and the tier-keyed cache lookup; package map adds internal/auth/{service,htpasswd,session}.
  • docs/configuration.md: full auth: schema documented; validation rules for the new fields listed.
  • docs/admin.md: clarifies that /admin/* HTTP Basic is separate from the public-facing IIIF auth services; cache key examples updated for the tiered shape; documents the live SSE dashboard, the /admin/stats/stream endpoint, and the per-route p95 sparkline; new "Configuration editor" section covering the curated field subset, the live-vs-restart split, and the save semantics.
  • docs/configuration.md: notes the admin config editor as a runtime path for changing a curated subset of fields, with a cross-reference to admin.md; documents image.max_concurrent_per_ip.
  • docs/deployment.md: new "Rate limiting and abuse protection" section — the edge-first stance, an nginx limit_req example, and the opt-in image.max_concurrent_per_ip cap with its trusted-proxy and shared-IP caveats. Also: the "Local build" + "Container" sections are replaced by a unified "Build variants" matrix (local binary / release binary / debian-slim image / distroless image) with linkage and license-implication call-outs per variant; a new "Redistribution compliance" matrix lists exactly which files to ship for each artifact; and a new "Running from a binary" section walks through the non-Docker deployment path (apt/brew install, the CI-published linux/amd64 release binary, a minimal systemd unit).
  • docs/caches.md: new "Auth-tier keying" section.
  • docs/iiif-compliance.md: new "IIIF Authorization Flow conformance" section.
  • README.md feature list gains the Auth 2.0 entry.
  • Release process spec. docs/specs/releases.md defines the release contract end-to-end: signed-tag trigger, pre-1.0 SemVer rules (breaking = IIIF API surface, config schema/env vars, or CLI flags), ad-hoc cadence, the fixed artifact set, latest semantics, forward-only rollback, the cut-release flow, and what's deliberately out of scope (cosign, SBOMs, multi-registry mirroring, pre-release channels). CHANGELOG.md header, docs/specs/scaffold.md "Versioning + releases", docs/deployment.md "Releases", README.md "Where to read next", and CLAUDE.md documentation- trigger table now cross-reference it.

0.2.0 - 2026-05-17

Added

  • HTJ2K (JPEG 2000 Part 15) decode via OpenJPEG ≥ 2.5.0. Sources in either form — boxed JP2/JPH or raw codestream (the default output of OpenJPH's ojph_compress) — flow through the IIIF pipeline. iiiris wraps raw codestreams in a minimal JP2 envelope on the fly so govips's format gate routes them to vips_jp2kload_buffer. Encode is classic Part 1 only — OpenJPEG doesn't implement HTJ2K encoding upstream. See docs/specs/htj2k.md.
  • testdata/sample.jph HTJ2K fixture (49 KiB synthetic gradient, generated via OpenJPH's ojph_compress; see tools/gen-htj2k-sample/README.md).
  • CORS on every IIIF response (Access-Control-Allow-Origin: *). The cors advertisement in extraFeatures is now load-bearing.
  • JSON-LD content negotiation on info.jsonAccept: application/ld+json produces application/ld+json;profile="… /context.json". The jsonldMediaType advertisement is now load-bearing.
  • Slash-collapse middleware — folds runs of / in the request path before routing. Fixes 404s from //foo-style URLs (which the stdlib ServeMux would otherwise 301-redirect, and many IIIF clients don't follow).
  • Validator fixture testdata/validator-squares.jpg (1000×1000 colored-grid JPEG matching the iiif-validator's hardcoded palette). Required by id_squares / size_wc / size_wh / size_ch / region_pixels. make testdata-validator regenerates.

Changed

  • Default release image is now distroless:vX.Y.Z and :latest are built from deploy/distroless/Dockerfile on gcr.io/distroless/static-debian12:nonroot. Static-linked libvips
  • OpenJPEG (with HTJ2K decode) + transitive image libs, no shell, no package manager. ~46 MB compressed. The Debian-slim image is retained as :vX.Y.Z-debian (no :latest-debian tag) for operators who need shell tooling in the container or hit a distroless-specific issue.
  • CI docker-build-distroless is no longer allow_failure — distroless build failures now block merges.
  • CI iiif-validate switches to --version=2.0 for v2 testing (the validator tags its tests 2.0 / 2.1.1 / 3.0; the prior --version=2.1 silently matched zero tests). After this change: v3 → 3 failures (all known upstream Python bugs / spec disagreements), v2 → 21 tests run with 2 failures (same upstream bugs).
  • CLAUDE.md "Documentation maintenance" rule expanded so a feature-list or release-tag-scheme change triggers a README.md update, and any user-shipping change triggers a CHANGELOG.md update (with "don't leave WIP entries past shipping" called out explicitly).

Fixed

  • info.json previously advertised jsonldMediaType and cors in extraFeatures but emitted neither header — both are now actually implemented.

Internal

  • Test coverage push for internal/source (18.6% → 85%), internal/config (0% → 100%), and internal/cache (56.5% → 84%). S3 backends in both packages refactored to take a small unexported API interface so unit tests can inject a fake — no minio sidecar or AWS calls in CI. Project-wide coverage ~54% → ~78%.

0.1.0 - 2026-05-11

First public iteration. Pre-1.0; the IIIF surface and most caching/source behavior is stable, but some edges (Lua hot-reload, IIIF validator in CI, Hook auth/redirect handler wiring) are still in flight.

Added

  • IIIF Image API 3.0 (Level 2) and 2.x parsers + endpoints, with multi-segment identifiers (paths with slashes).
  • libvips image pipeline via govips: full region / size / rotation / quality (incl. true bitonal) / format coverage.
  • Three caches (RenderCache, InfoCache, OriginCache) × three backends (heap, filesystem, S3 with multipart streaming). Filesystem cache has size-bounded LRU eviction.
  • Three source backends: filesystem, HTTP, S3 (S3 supports listing).
  • Hook engines: noop, lookup (text/template), lua (sandboxed gopher-lua), webhook (HTTP POST per call).
  • Operator admin UI under /admin/: status, config, cache management (purge / purge-all), file browser, OpenSeadragon viewer (vendored). HTTP Basic auth gated on IIIRIS_ADMIN_USER / IIIRIS_ADMIN_PASS.
  • Conditional GET (If-None-Match / If-Modified-Since → 304).
  • Zero-config startup (binary works with no flags, no file, no env vars).
  • Multi-arch container image (linux/amd64 + linux/arm64) and a Linux binary on tagged releases via GitLab CI.