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.

Report bundle assembly and promotion Diagram showing a PDF, assets, and a data snapshot combined with a manifest and SHA-256 checksums into a versioned .tar.gz bundle, published to an artifact registry, then promoted unchanged from a staging channel to a production channel. report.pdf assets + fonts data snapshot versioned bundle manifest.json SHA-256SUMS registry v1.4.0 · staging promote → 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.

Python
# 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.

Python
# 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.

Python
# 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.

Python
# 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.

Python
# 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 arcname to report-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:

Bash
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.