Publishing Report Bundles to an Artifact Registry
A single PDF rarely travels alone. A finished spatial deliverable is often a bundle: the report itself, the map images and fonts it embeds, the source data snapshot, and a manifest tying them together with checksums and a version. Treating that bundle as one versioned, checksummed artifact — published to GitHub Releases, a GitLab Package Registry, or a generic registry — lets you pin, verify, and promote exactly what shipped. This guide, part of the Report Artifact Storage & Versioning workflow, covers assembling the bundle, writing a manifest with checksums, applying semantic-style versions, publishing through a registry API, and promoting the identical bytes from staging to production.
Prerequisites
- Python 3.10+ (standard-library
tarfile,hashlib,json) - A rendered report plus its assets — the output of the container built in Installing WeasyPrint Dependencies in Slim Docker Images
- A publish target: a GitHub repo (Releases), a GitLab project (Package Registry), or any registry with an HTTP upload API
- A token with upload scope stored as a CI secret
Step 1: Assemble the Bundle Contents
Stage everything that must ship together in one directory tree, then archive it. Keeping a predictable internal layout means consumers know where to find the PDF and its provenance without guessing.
# bundle.py
from __future__ import annotations
import shutil, tarfile
from pathlib import Path
def stage_bundle(staging: Path, pdf: Path, assets: list[Path], data: Path) -> Path:
staging.mkdir(parents=True, exist_ok=True)
shutil.copy2(pdf, staging / "report.pdf")
(staging / "assets").mkdir(exist_ok=True)
for a in assets:
shutil.copy2(a, staging / "assets" / a.name)
(staging / "data").mkdir(exist_ok=True)
shutil.copy2(data, staging / "data" / data.name)
return staging
The data snapshot is what makes a bundle reproducible: shipping the exact input alongside the output means anyone can re-render and confirm the report from source, which matters for the multilingual and layout-sensitive documents described in Typography Mapping for Multi-Language Spatial Data.
Step 2: Write a Manifest and Checksums
A manifest is the bundle’s table of contents and integrity record in one file. List every path, its size, and its SHA-256 so a consumer can verify nothing was altered or truncated in transit.
# manifest.py
from __future__ import annotations
import hashlib, json
from pathlib import Path
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def write_manifest(staging: Path, version: str) -> Path:
files = [p for p in staging.rglob("*") if p.is_file() and p.name != "manifest.json"]
entries = [{
"path": str(p.relative_to(staging)),
"bytes": p.stat().st_size,
"sha256": sha256_of(p),
} for p in sorted(files)]
manifest = {"version": version, "files": entries, "file_count": len(entries)}
out = staging / "manifest.json"
out.write_text(json.dumps(manifest, indent=2, sort_keys=True))
return out
Reading files in 1 MiB chunks keeps memory flat even when the data snapshot is large. The per-file checksums here are the bundle-level analogue of the object-level sha256 metadata used in Versioning Generated PDFs in S3 Object Storage.
Step 3: Version the Bundle
Give the bundle a semantic-style version so consumers can pin and compare. For reports, read the three components as: MAJOR for a template or layout change that alters appearance, MINOR for added sections or datasets, PATCH for a data refresh with no structural change.
# version.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class BundleVersion:
major: int
minor: int
patch: int
def __str__(self) -> str:
return f"v{self.major}.{self.minor}.{self.patch}"
def bump_patch(self) -> "BundleVersion": # nightly data refresh
return BundleVersion(self.major, self.minor, self.patch + 1)
A nightly run that only refreshes data bumps PATCH, so v1.4.0 → v1.4.1 signals “same report, newer numbers.” A template redesign bumps MAJOR, warning downstream consumers that the layout — and any hard-coded page extraction — may have moved.
Step 4: Publish to a Registry
Archive the staged tree, then upload it as a release asset or package. The GitHub Releases API is a pragmatic generic registry: create a release for the tag, then upload the tarball to its asset endpoint.
# publish.py
from __future__ import annotations
import subprocess, tarfile
from pathlib import Path
def archive(staging: Path, version: str) -> Path:
out = staging.parent / f"report-bundle-{version}.tar.gz"
with tarfile.open(out, "w:gz") as tar:
tar.add(staging, arcname=f"report-bundle-{version}")
return out
def publish_github(tarball: Path, version: str, repo: str) -> None:
# gh reads GITHUB_TOKEN from the environment
subprocess.run(
["gh", "release", "create", version, str(tarball),
"--repo", repo, "--title", f"Report bundle {version}",
"--notes", f"Automated spatial report bundle {version}"],
check=True,
)
For a GitLab target, swap the last call for a PUT to the generic Package Registry endpoint (/packages/generic/report-bundle/<version>/<file>) with a PRIVATE-TOKEN header. The bundle contents and checksums are identical regardless of registry — only the transport differs.
Step 5: Promote Between Environments
Promotion must move the same bytes, never a rebuild. Rebuilding risks a different font subset, a newer dependency, or a shifted layout sneaking into production. Download the staging bundle, verify its checksums against the manifest, and re-publish the identical tarball to the production channel.
# promote.py
from __future__ import annotations
import hashlib, json, tarfile
from pathlib import Path
def verify_and_promote(tarball: Path) -> bool:
with tarfile.open(tarball, "r:gz") as tar:
root = Path(tar.getnames()[0].split("/")[0])
tar.extractall(path="/tmp/promote")
staged = Path("/tmp/promote") / root
manifest = json.loads((staged / "manifest.json").read_text())
for entry in manifest["files"]:
got = hashlib.sha256((staged / entry["path"]).read_bytes()).hexdigest()
if got != entry["sha256"]:
raise ValueError(f"checksum mismatch on {entry['path']}")
return True # safe to re-tag the same artifact into the production channel
Because the artifact is content-addressed by its manifest, “promotion” is just re-pointing a production tag at bytes that already passed staging — the same immutability guarantee that Object Lock provides at the object level.
Key Parameters / Configuration Reference
| Item | Where | Value | Notes |
|---|---|---|---|
manifest.json |
bundle root | JSON | Path, size, and SHA-256 per file |
| version scheme | tag | vMAJOR.MINOR.PATCH |
PATCH = data refresh; MAJOR = layout change |
| archive format | tarball | .tar.gz |
arcname roots the tree by version |
| chunk size | checksums | 1 << 20 |
1 MiB reads keep memory flat |
| upload scope | token | release/package write | Store as a CI secret, never in the repo |
| promotion | channels | re-tag, no rebuild | Verify checksums before re-publishing |
Common Pitfalls
- Rebuilding on promotion. Re-rendering for production can pull a newer dependency or font and diverge from what staging approved. Promote the identical checksummed tarball; never rebuild.
- Omitting the data snapshot. A bundle without its input data cannot be reproduced or audited. Include the source snapshot so the report can be regenerated from the archive alone.
- Flat tarballs without a version root. Extracting an archive whose members sit at the top level scatters files into the current directory. Set
arcnametoreport-bundle-<version>so extraction is self-contained. - Treating every nightly run as a MAJOR bump. Version inflation makes the scheme meaningless. Reserve MAJOR for layout or template changes; a data-only refresh is a PATCH.
Verification
Confirm the published bundle downloads, its checksums match the manifest, and the version tag resolves:
gh release download v1.4.1 --repo org/reports --pattern 'report-bundle-*.tar.gz'
python - <<'PY'
from pathlib import Path
from promote import verify_and_promote
tarball = next(Path(".").glob("report-bundle-*.tar.gz"))
assert verify_and_promote(tarball), "bundle failed checksum verification"
print("bundle verified:", tarball.name)
PY
A clean verify_and_promote return proves the manifest and every file’s SHA-256 agree, so the bundle you downloaded is byte-for-byte the one that was published — the precondition for promoting it to production untouched.
Related
- Report Artifact Storage & Versioning — parent section covering the artifact-storage workflow
- Versioning Generated PDFs in S3 Object Storage — the sibling guide for object-level PDF versioning
- Installing WeasyPrint Dependencies in Slim Docker Images — the container that renders the report inside each bundle
- Typography Mapping for Multi-Language Spatial Data — the fonts and assets bundled alongside the PDF