Fishwrap Image Contract
This document is the public contract for consumers of the fishwrap engine. If you depend on fishwrap, you depend on this contract, and only this contract. Anything not described here is an implementation detail that may change at any time.
If you are looking for the internal decision record of why fishwrap publishes as a container image, see ADR-001.
The canonical example consumer is Daily Clamour. Fork it as a starting template.
1. The Artifact
Every fishwrap release publishes one OCI container image to GitHub Container Registry, with SLSA build provenance and a CycloneDX SBOM attached as Sigstore attestations.
ghcr.io/maxspevack/fishwrap:<tag>
- Architectures:
linux/amd64. (arm64 is not currently published.) - Where it’s built: images are built and published exclusively by the release workflow at https://github.com/maxspevack/fishwrap/blob/main/.github/workflows/release.yml on tag pushes. The image’s source is whatever commit the corresponding git tag points at.
- Verifying provenance:
gh attestation verify oci://ghcr.io/maxspevack/fishwrap:<tag> \ --owner maxspevackConfirms the image digest was produced by the named release workflow at the tagged commit. SLSA Build Level 2.
- Retrieving the SBOM: the CycloneDX JSON is uploaded as a workflow artifact (
fishwrap-sbom.cdx.json, 90-day retention) and attached to the image as a Sigstore SBOM attestation. Pull viacosign download attestationor via the GitHub release artifact list.
Every release publishes two tags:
| Tag form | Example | Mutability |
|---|---|---|
| Exact | 2.0.2 | Immutable. Recommended for production pinning. |
| Floating minor | 2.0 | Republished on every patch within the minor line. Useful for development. Do not pin production deploys to floating tags — see §6 (Pinning). |
Pre-release tags (-rc1, -alpha.1, etc.) publish only the exact tag and do not update floating tags.
2. Inputs — What You Mount In
The image expects two mount points:
/cfg (required)
Your configuration directory. The engine looks for these files inside it:
| Path | Required | Purpose |
|---|---|---|
/cfg/config.py | Yes | Editorial configuration. See docs/CONFIG_SCHEMA.md for the schema. |
/cfg/secrets.json | No | JSON object mapping source-specific keys to auth material (cookies, API tokens). Absent = sources requiring auth fetch in degraded mode. The engine reads only the keys it needs and ignores the rest. |
Beyond these two, anything else you place under /cfg is yours — typically your theme directory, About-page content, or other consumer-specific assets your config references.
/output (recommended, writable)
The engine writes outputs to paths your config.py specifies (see §3). The canonical pattern is to point those config paths under /output/... and mount this directory writable from the host:
podman run --rm \
-v $(pwd)/my-config-dir:/cfg \
-v $(pwd)/my-output-dir:/output \
ghcr.io/maxspevack/fishwrap:2.0.0 \
fishwrap-build --config /cfg/config.py
If your config instead writes outputs under /cfg/ (using a CONFIG_BASE_DIR-relative pattern), the /cfg mount alone suffices and a separate /output mount is unnecessary. Daily Clamour uses this latter pattern — its config sets LATEST_HTML_FILE = os.path.join(CONFIG_BASE_DIR, 'output/index.html'), which resolves to /cfg/output/index.html inside the container and lands at the host’s project-root output/ via the /cfg bind. Either layout is supported.
What is not part of the input contract
- Process working directory. The engine works from any CWD. Do not assume it is
/app,/cfg, or anywhere else — it is unspecified. - Environment variables. The engine reads
FISHWRAP_CONFIGinternally;entrypoint.shsets it for you when you usefishwrap-build --config <path>. Other environment variables are implementation detail. - Anything outside
/cfgand/output. The container’s filesystem layout is implementation detail.
3. Outputs — What You Get Back
After fishwrap-build completes successfully, the engine writes outputs to the paths your config.py specifies. The four output artifacts and the config keys that control them are:
| Logical artifact | Config key | Format | Stability |
|---|---|---|---|
| Edition HTML | LATEST_HTML_FILE | UTF-8 HTML (rendered Jinja2 against your theme) | Stable across patch and minor versions. |
| Transparency fragment | written alongside RUN_SHEET_FILE as transparency_fragment.html | UTF-8 HTML fragment (the audit/transparency report block) | Stable across patch and minor versions. Designed to be embedded in an “About” or “Methodology” page. |
| Run sheet | RUN_SHEET_FILE | UTF-8 JSON (machine-readable record of the published edition) | Stable across patch and minor versions. The schema may grow (additive fields), never shrink. |
| Edition PDF | LATEST_PDF_FILE | Currently not produced in v2.0.x. PDF generation is out of scope until a future release; configs may declare the path but the file will not be written. Setting the path is harmless. |
The canonical pattern points these config keys under /output/ (see §2) so they land at predictable container paths regardless of how /cfg is laid out. Configs that use CONFIG_BASE_DIR-relative paths and write outputs under /cfg/output/ are equally supported — Daily Clamour’s reference config does this.
What is not part of the output contract
- Exact byte counts of any output file. Output content varies based on input feeds, scoring, and timestamps.
- Filesystem permissions of output files (consumers should not depend on specific ownership beyond “writable by the user that ran the container”).
- Order of fields within
run_sheet.json(treat as an unordered JSON object). - Side-effect files inside
/cfg(e.g., the engine writes the SQLite newsroom database alongside the config if the config’sDATABASE_URLresolves there). State persistence is implementation detail; the only durable artifact is what lands in/output.
4. Invocation — How You Run It
The image exposes four documented invocations.
fishwrap-build --config <path>
Runs the full editorial pipeline (fetch → edit → enhance → print) against the config at <path>.
podman run --rm \
-v $(pwd)/my-config-dir:/cfg \
-v $(pwd)/my-output-dir:/output \
ghcr.io/maxspevack/fishwrap:2.0.0 \
fishwrap-build --config /cfg/config.py
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Pipeline completed; outputs produced. |
| 2 | Argument error (missing --config, file not found). |
| Non-zero (other) | Engine error. Stderr contains diagnostic detail. |
fishwrap-version
Prints the running image’s version to stdout, as a single line, parseable as a semver string. Nothing else — no banner, no whitespace surprises.
$ podman run --rm ghcr.io/maxspevack/fishwrap:2.0.0 fishwrap-version
2.0.0
This is the only supported way to read the version. Do not import the Python package and read __version__; the Python module layout is implementation detail.
fishwrap-validate-config <path>
Schema-checks a config file before you commit to a build. Use this in CI and in pre-commit hooks to fail in 100 ms instead of 4 minutes when a typo or missing key would otherwise crash mid-fetch.
podman run --rm \
-v $(pwd):/cfg \
ghcr.io/maxspevack/fishwrap:2.0.0 \
fishwrap-validate-config /cfg/config.py
Validates that required keys are present (FEEDS, SECTIONS) and that all defined keys have the right top-level type and shape. Does not validate semantics — URLs are not probed, timezones are not checked against IANA, file paths are not required to exist.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Config is valid. |
| 1 | Config is invalid; stderr lists each problem prefixed error: (one per line). |
| 2 | Usage error (wrong args, file not found, parse failure). |
Generic Python via --entrypoint python
For downstream scripts that need to call into the fishwrap library, override the entrypoint. The image’s Python interpreter has fishwrap on PYTHONPATH.
podman run --rm \
--entrypoint python \
-v $(pwd):/cfg \
ghcr.io/maxspevack/fishwrap:2.0.0 \
/cfg/your-script.py
This is how Daily Clamour runs its publish_about.py glue script. In addition to the standard library, downstream scripts may rely on jinja2 being importable inside the image — see §7 for the full list of runtime guarantees. The library surface of fishwrap itself (e.g., from fishwrap.db.repository import ...) is not part of the public contract — it may change between minor versions. If you find yourself relying on a specific internal API, file an issue: that is a signal we should be exposing a CLI for it instead.
5. Versioning Policy
Fishwrap follows Semantic Versioning 2.0.0 per docs/VERSIONING.md. For consumers of this image, the policy means:
| Bump type | Example | What changes | What you should do |
|---|---|---|---|
| Patch | 2.0.1 → 2.0.2 | Bug fixes only. Output is stable on identical input. | Adopt automatically (e.g., via Dependabot PRs that pass CI). |
| Minor | 2.0.x → 2.1.0 | New features. Output may change in non-breaking ways (e.g., new fields in run_sheet.json, new optional config keys). | Review release notes before adopting. Run your own validation. |
| Major | vX.x.x → v(X+1).0.0 | Breaking changes to the image contract: input/output paths, file formats, entrypoints, or invocation. | Read the migration notes. Expect to modify your config, scripts, or CI. |
Pre-release identifiers (-rc1, -alpha.1, -beta) are unstable and do not move floating tags. Treat them as preview-only.
6. Pinning Recommendation
Pin to exact tags (e.g., 2.0.2), not floating minors (e.g., 2.0).
Reasoning:
- The same git SHA in your repo should produce the same deployed image, every time. Floating tags break that property by definition — same source, different image, depending on when CI runs.
- Adopting new patches via reviewed PRs (e.g., from Dependabot) lets you see the upgrade work before it hits production, by running your own pipeline against the candidate image as a required check.
Daily Clamour’s pinning setup (Dockerfile-tracked pin + Dependabot + production-refresh as required check) is documented in its own version policy and is the canonical example.
7. Stability Guarantees
The following are part of the contract. Changes to any of them require at minimum a minor version bump (or a major bump if the change is breaking):
- The mount points:
/cfg(required) and/output(recommended; see §2). - The four invocations described in §4 (
fishwrap-build,fishwrap-version,fishwrap-validate-config, generic Python entrypoint). - The output formats described in §3. Output paths are determined by your config; the engine writes wherever your config points.
- The image’s identifier (
ghcr.io/maxspevack/fishwrap). - The image running as a non-root user. (UID is currently 1000; the non-rootness is contract, the specific UID is implementation detail.)
- The image’s Python interpreter has
jinja2available onPYTHONPATHfor downstream scripts invoked via the generic Python entrypoint (§4). Other third-party Python packages are not part of the contract.
The following are not part of the contract. They may change at any time, including in patch releases, with no notice:
- The base image (
python:3.12-slimtoday). - The Python version (3.12 today).
- The exact list of installed system packages.
- The internal filesystem layout (paths under
/usr,/opt,/app, etc.). - The
__version__attribute on thefishwrapPython package — usefishwrap-versioninstead. - The internal Python module layout (
fishwrap.fetcher,fishwrap.db.models, etc.). - The image size, layer structure, or build cache topology.
- The exact entropy of timestamps, run IDs, or other monotonic fields in output files.
8. Canonical Consumer Template
Daily Clamour is the reference implementation for “consume fishwrap as an image.” Specific files worth reading before you fork:
Dockerfile— single-line image pin tracked by Dependabot. The file’s only purpose is to be a Dependabot manifest; the Makefile and workflow bothawkitsFROMline at run time.docs/VERSION_POLICY.md— exact-pin reasoning, Dependabot procedure, Dependabot-vs-Renovate trade-offs, manual override path..github/workflows/refresh.yml— daily cron that pulls the image, validates config, builds, gates on size + structural assertions, deploys viaactions/deploy-pages. Includes the canonical secrets-injection pattern (GH secret → temp file → bind mount).scripts/structural_test.py— example of post-build assertions that catch silent regressions the size gate misses (section disappearance, Jinja leaks, scoring collapse).OPERATIONS.md— operator manual covering daily flow, version bumps, cookie rotation, manual triggers, and troubleshooting.Makefile— runtime-agnostic Make wrapper. Auto-detectsdockervspodman, handles rootless podman user-namespace remap (--userns=keep-id) and SELinux relabeling (:z), invokes the documented CLI surface.
DC demonstrates:
- Pinning the image version in a
Dockerfileso Dependabot can track upgrades. - Mounting a config directory at
/cfg. (DC’sconfig.pyusesCONFIG_BASE_DIR-relative paths, so a separate/outputmount is unnecessary — see §2 for both supported layouts.) - Running
fishwrap-buildfrom GitHub Actions on a daily cron. - Capturing
fishwrap-versionfor downstream metadata injection — no Python imports of fishwrap anywhere in the consumer’s source. - Pre-build (
fishwrap-validate-config) and post-build (size gate + structural assertions) validation as deploy gates. The Dependabot PR’s required check is the full production refresh against the candidate image. - Secrets travel via GitHub Actions secrets, written to a temp file at workflow runtime, mounted at
/cfg/secrets.json.
To start your own fishwrap-powered publication, fork the Daily Clamour repository and replace the config, theme, and content. You should not need to read fishwrap source code at any point.
9. Cross-References
- ADR-001 — Release Artifact Contract — internal decision record this contract derives from
docs/VERSIONING.md— SemVer policydocs/CONFIG_SCHEMA.md— full config schema (companion document)docs/RELEASING.md— release process used to produce these images- Daily Clamour — canonical consumer
- GitHub Releases — release notes for each version