Files
qqq-all/scripts/release_bundle.py
james.maes 1a8d393d03 ci: prepare exact RC1 distribution delivery
Extend the existing release gates and ship an image Compose bundle. Refs QRun-IO/qqq#921.
2026-10-01 20:12:51 -05:00

100 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Package digest-pinned Compose with the tracked full-profile infrastructure."""
import argparse
import json
import os
import re
import shutil
import subprocess
import tarfile
from pathlib import Path
from release_gate import validate_version
def create_bundle(root, version, sha, image, output):
validate_version(version)
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("Source SHA must be a full commit ID")
if not re.fullmatch(r"ghcr\.io/qrun-io/qqq-all@sha256:[0-9a-f]{64}", image):
raise ValueError("Release image must be the qqq-all GHCR digest")
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
rendered = subprocess.run(
["docker", "compose", "--env-file", ".env.example", "--profile", "*", "-f", "compose.yaml",
"config", "--no-interpolate", "--no-env-resolution", "--no-path-resolution", "--format", "json"],
cwd=root, env=environment, capture_output=True, text=True, check=True,
)
compose = json.loads(rendered.stdout)
compose.pop("x-app-image", None)
for name in ("app-core", "app-full"):
app = compose["services"][name]
app.pop("build", None)
app["image"] = image
app["pull_policy"] = "always"
compose["services"]["minio"]["image"] = "qqq-all-minio:" + version
name = "qqq-all-" + version
bundle = output / name
bundle.mkdir(parents=True, exist_ok=False)
(bundle / "compose.yaml").write_text(json.dumps(compose, indent=2) + "\n")
tracked = subprocess.check_output(["git", "-C", str(root), "ls-files", "--", "infra"], text=True).splitlines()
for filename in (".env.example", "LICENSE", "NOTICE", *tracked):
source = root / filename
if source.is_symlink() or not source.is_file():
raise ValueError(f"Bundle input must be a regular file: {filename}")
target = bundle / filename
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
(bundle / "release.json").write_text(json.dumps(
{"version": version, "source_sha": sha, "image": image}, indent=2) + "\n")
(bundle / "README.md").write_text(f"""# qqq-all {version}
Extract this complete bundle and run commands from this directory. The application uses
`{image}` (source `{sha}`). No application source, Maven or app build is required.
Docker with Compose v2 is required. Copy `.env.example` to `.env` and replace
the demonstration passwords before sharing the environment. All fixture values remain
demo-only; retain the infra directory next to compose.yaml.
```bash
cp .env.example .env
docker compose -p qqq-all-demo --profile core up -d --wait --no-build
```
For the full profile, stop core first. The included MinIO Dockerfile still builds locally
and needs network access; PostgreSQL/MySQL/Mongo initialization and the Keycloak realm
use the bundled infra files. External service images also need network access.
```bash
docker compose -p qqq-all-demo --profile core down
docker compose -p qqq-all-demo --profile full build minio
docker compose -p qqq-all-demo --profile full up -d --wait --no-build
```
Use a distinct Compose project name for each installation. The application listens on
http://localhost:8080 by default. The full-profile identity provider uses
http://keycloak.localhost:8081; that hostname must resolve to loopback on the client.
Ports and fixture settings are in `.env.example`. `docker compose ... down` retains
named data volumes; remove volumes only when intentionally resetting that installation.
The standalone compose.yaml asset needs this bundle's infra and environment template
for full-profile operation. See release.json for the exact application identity.
""")
archive = output / (name + ".tar.gz")
with tarfile.open(archive, "x:gz") as handle:
handle.add(bundle, arcname=name)
return archive
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--sha", required=True)
parser.add_argument("--image", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
print(create_bundle(args.root.resolve(), args.version, args.sha, args.image, args.output.resolve()))
if __name__ == "__main__":
main()