Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build or verify a checksummed and SSH-signed GeoIntel release package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_NAME = "release-manifest.json"
|
||||
SIGNATURE_NAME = f"{MANIFEST_NAME}.sig"
|
||||
SIGNERS_NAME = "allowed_signers"
|
||||
CHECKSUMS_NAME = "CHECKSUMS.sha256"
|
||||
GENERATED_NAMES = {MANIFEST_NAME, SIGNATURE_NAME, SIGNERS_NAME, CHECKSUMS_NAME}
|
||||
RELEASE_ID_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
def run(
|
||||
command: Sequence[str],
|
||||
*,
|
||||
input_bytes: bytes | None = None,
|
||||
cwd: Path = ROOT,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
result = subprocess.run(
|
||||
list(command),
|
||||
cwd=cwd,
|
||||
input=input_bytes,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"{' '.join(command)} failed: {stderr}")
|
||||
return result
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def package_files(package_dir: Path, *, include_generated: bool) -> list[Path]:
|
||||
result: list[Path] = []
|
||||
for path in sorted(package_dir.rglob("*"), key=lambda item: item.as_posix()):
|
||||
if path.is_symlink():
|
||||
raise RuntimeError(f"Release packages may not contain symlinks: {path}")
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(package_dir)
|
||||
if not include_generated and relative.as_posix() in GENERATED_NAMES:
|
||||
continue
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def public_key(signing_key: Path) -> str:
|
||||
public_path = Path(f"{signing_key}.pub")
|
||||
if public_path.is_file():
|
||||
value = public_path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
value = run(("ssh-keygen", "-y", "-f", str(signing_key))).stdout.decode(
|
||||
"utf-8"
|
||||
).strip()
|
||||
if not value.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-")):
|
||||
raise RuntimeError("Unsupported or invalid SSH public signing key")
|
||||
return " ".join(value.split()[:2])
|
||||
|
||||
|
||||
def write_checksums(package_dir: Path) -> None:
|
||||
lines = []
|
||||
for path in package_files(package_dir, include_generated=True):
|
||||
relative = path.relative_to(package_dir).as_posix()
|
||||
if relative == CHECKSUMS_NAME:
|
||||
continue
|
||||
lines.append(f"{sha256(path)} {relative}")
|
||||
(package_dir / CHECKSUMS_NAME).write_text(
|
||||
"\n".join(lines) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
|
||||
def verify_checksums(package_dir: Path) -> None:
|
||||
checksum_path = package_dir / CHECKSUMS_NAME
|
||||
if not checksum_path.is_file():
|
||||
raise RuntimeError(f"Missing {CHECKSUMS_NAME}")
|
||||
expected_paths: set[str] = set()
|
||||
for line in checksum_path.read_text(encoding="utf-8").splitlines():
|
||||
digest, separator, relative = line.partition(" ")
|
||||
if not separator or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise RuntimeError(f"Invalid checksum line: {line!r}")
|
||||
candidate = (package_dir / relative).resolve()
|
||||
try:
|
||||
candidate.relative_to(package_dir)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Checksum path escapes package: {relative}") from exc
|
||||
if not candidate.is_file() or candidate.is_symlink():
|
||||
raise RuntimeError(f"Checksummed release file is unavailable: {relative}")
|
||||
if sha256(candidate) != digest:
|
||||
raise RuntimeError(f"Checksum mismatch: {relative}")
|
||||
expected_paths.add(relative)
|
||||
actual_paths = {
|
||||
path.relative_to(package_dir).as_posix()
|
||||
for path in package_files(package_dir, include_generated=True)
|
||||
if path.relative_to(package_dir).as_posix() != CHECKSUMS_NAME
|
||||
}
|
||||
if expected_paths != actual_paths:
|
||||
raise RuntimeError(
|
||||
"Checksum inventory differs from package contents: "
|
||||
f"missing={sorted(actual_paths - expected_paths)}, "
|
||||
f"unexpected={sorted(expected_paths - actual_paths)}"
|
||||
)
|
||||
|
||||
|
||||
def verify_package(package_dir: Path) -> dict[str, object]:
|
||||
package_dir = package_dir.expanduser().resolve()
|
||||
verify_checksums(package_dir)
|
||||
manifest_path = package_dir / MANIFEST_NAME
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
identity = str(manifest["signature"]["identity"])
|
||||
namespace = str(manifest["signature"]["namespace"])
|
||||
run(
|
||||
(
|
||||
"ssh-keygen",
|
||||
"-Y",
|
||||
"verify",
|
||||
"-f",
|
||||
str(package_dir / SIGNERS_NAME),
|
||||
"-I",
|
||||
identity,
|
||||
"-n",
|
||||
namespace,
|
||||
"-s",
|
||||
str(package_dir / SIGNATURE_NAME),
|
||||
),
|
||||
input_bytes=manifest_path.read_bytes(),
|
||||
)
|
||||
if manifest.get("scope") != "Belgium and the Belgian North Sea":
|
||||
raise RuntimeError("Unexpected release scope")
|
||||
if manifest.get("release_id") != f"v{manifest.get('version')}":
|
||||
raise RuntimeError("Release id and semantic version differ")
|
||||
return manifest
|
||||
|
||||
|
||||
def build_package(args: argparse.Namespace) -> dict[str, object]:
|
||||
package_dir = args.output_dir.expanduser().resolve()
|
||||
package_dir.mkdir(parents=True, exist_ok=True)
|
||||
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
release_id = args.release_id or f"v{version}"
|
||||
if not RELEASE_ID_RE.fullmatch(release_id) or release_id != f"v{version}":
|
||||
raise RuntimeError("Release id must equal v<VERSION> and be valid SemVer")
|
||||
|
||||
commit = run(("git", "rev-parse", "HEAD")).stdout.decode().strip()
|
||||
dirty = run(("git", "status", "--porcelain=v1")).stdout.decode().strip()
|
||||
if dirty:
|
||||
raise RuntimeError("Release package requires a clean Git worktree")
|
||||
tag_commit = run(("git", "rev-list", "-n", "1", release_id)).stdout.decode().strip()
|
||||
if tag_commit != commit:
|
||||
raise RuntimeError(f"Tag {release_id} does not point to HEAD")
|
||||
if args.image_revision != commit:
|
||||
raise RuntimeError("Image revision must equal the tagged Git commit")
|
||||
|
||||
evidence = []
|
||||
for path in package_files(package_dir, include_generated=False):
|
||||
evidence.append(
|
||||
{
|
||||
"path": path.relative_to(package_dir).as_posix(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sha256": sha256(path),
|
||||
}
|
||||
)
|
||||
if not evidence:
|
||||
raise RuntimeError("At least one release evidence file is required")
|
||||
|
||||
signing_key = args.signing_key.expanduser().resolve()
|
||||
if not signing_key.is_file():
|
||||
raise RuntimeError(f"SSH signing key is unavailable: {signing_key}")
|
||||
identity = args.identity
|
||||
namespace = "geointel-release"
|
||||
(package_dir / SIGNERS_NAME).write_text(
|
||||
f"{identity} {public_key(signing_key)}\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"version": version,
|
||||
"scope": "Belgium and the Belgian North Sea",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"git": {
|
||||
"commit": commit,
|
||||
"tag": release_id,
|
||||
"clean": True,
|
||||
},
|
||||
"image": {
|
||||
"name": args.image_name,
|
||||
"id": args.image_id,
|
||||
"revision": args.image_revision,
|
||||
},
|
||||
"signature": {
|
||||
"algorithm": "SSH",
|
||||
"identity": identity,
|
||||
"namespace": namespace,
|
||||
},
|
||||
"evidence": evidence,
|
||||
}
|
||||
manifest_path = package_dir / MANIFEST_NAME
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
signature_path = package_dir / SIGNATURE_NAME
|
||||
if signature_path.exists():
|
||||
signature_path.unlink()
|
||||
run(
|
||||
(
|
||||
"ssh-keygen",
|
||||
"-Y",
|
||||
"sign",
|
||||
"-f",
|
||||
str(signing_key),
|
||||
"-n",
|
||||
namespace,
|
||||
str(manifest_path),
|
||||
)
|
||||
)
|
||||
if not signature_path.is_file():
|
||||
raise RuntimeError("ssh-keygen did not create the detached signature")
|
||||
write_checksums(package_dir)
|
||||
return verify_package(package_dir)
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
build = subparsers.add_parser("build")
|
||||
build.add_argument("--output-dir", type=Path, required=True)
|
||||
build.add_argument("--release-id")
|
||||
build.add_argument("--image-name", required=True)
|
||||
build.add_argument("--image-id", required=True)
|
||||
build.add_argument("--image-revision", required=True)
|
||||
build.add_argument(
|
||||
"--signing-key",
|
||||
type=Path,
|
||||
default=os.environ.get("GEOINTEL_RELEASE_SIGNING_KEY"),
|
||||
required=not bool(os.environ.get("GEOINTEL_RELEASE_SIGNING_KEY")),
|
||||
)
|
||||
build.add_argument("--identity", default="geointel-release")
|
||||
verify = subparsers.add_parser("verify")
|
||||
verify.add_argument("--package-dir", type=Path, required=True)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
if args.command == "build":
|
||||
manifest = build_package(args)
|
||||
print(
|
||||
f"Built and verified signed release package "
|
||||
f"{manifest['release_id']} at {args.output_dir.resolve()}"
|
||||
)
|
||||
else:
|
||||
manifest = verify_package(args.package_dir)
|
||||
print(
|
||||
f"Verified signed release package "
|
||||
f"{manifest['release_id']} at {args.package_dir.resolve()}"
|
||||
)
|
||||
except (OSError, KeyError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user