blob: c18a3f0799d86067b87971970682bab2b2db9e8a [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Assemble native package artifacts into a checksummed publishable bundle."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
from pathlib import Path
from typing import Sequence
DEB_PROPERTIES = {
"deb.distribution": ["jammy", "noble"],
"deb.component": ["release"],
}
DEB_PLATFORMS = {"ubuntu22.04-amd64", "ubuntu22.04-arm64"}
RPM_PLATFORMS = {"almalinux9-x86_64", "almalinux9-aarch64"}
WINDOWS_PLATFORMS = {"windows-msvc-x86_64", "windows-msvc-arm64"}
REQUIRED_VERSIONS = {"archive_version", "homebrew_version"}
REQUIRED_SOURCE = {"commit", "repository"}
SDK_PLATFORMS = {
"ubuntu22.04-amd64",
"ubuntu22.04-arm64",
"almalinux9-x86_64",
"almalinux9-aarch64",
"macos-arm64",
"macos-x86_64",
"windows-msvc-x86_64",
"windows-msvc-arm64",
"manylinux_2_28-x86_64",
"manylinux_2_28-aarch64",
"macos12-x86_64",
"macos12-arm64",
}
PYTHON_PLATFORMS = {
"manylinux_2_28-x86_64",
"manylinux_2_28-aarch64",
"macos12-x86_64",
"macos12-arm64",
"windows-msvc-x86_64",
}
PYTHON_TAGS = {"cp39", "cp310", "cp311", "cp312", "cp313", "cp314"}
def _wheel_metadata(filename: str, platform: str) -> dict[str, str]:
match = re.fullmatch(r"tsfile-([^-]+)-(cp\d+)-(cp\d+)-([^-]+)\.whl", filename)
if match is None:
raise ValueError(f"unsupported Python wheel filename: {filename}")
version, python_tag, abi_tag, platform_tags = match.groups()
if python_tag not in PYTHON_TAGS or abi_tag != python_tag:
raise ValueError(f"unsupported Python wheel ABI: {filename}")
tags = platform_tags.split(".")
if platform.startswith("manylinux_2_28-"):
architecture = platform.removeprefix("manylinux_2_28-")
valid = f"manylinux_2_28_{architecture}" in tags and all(
re.fullmatch(rf"manylinux(?:_\d+_\d+|\d+)_{architecture}", tag)
for tag in tags
)
elif platform.startswith("macos12-"):
architecture = platform.removeprefix("macos12-")
matches = [
re.fullmatch(rf"macosx_(\d+)_(\d+)_{architecture}", tag) for tag in tags
]
valid = all(
item is not None and tuple(map(int, item.groups())) <= (12, 0)
for item in matches
)
else:
valid = tags == ["win_amd64"]
if not valid:
raise ValueError(f"incompatible Python wheel tags for {platform}: {filename}")
return {
"pythonTag": python_tag,
"abiTag": abi_tag,
"wheelVersion": version,
"sdkPlatform": platform,
}
def _validate_metadata(values: dict[str, str], required: set[str], name: str) -> None:
if not isinstance(values, dict) or not required.issubset(values):
raise ValueError(f"{name} must contain {sorted(required)}")
if any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in values.items()
):
raise ValueError(f"{name} must map strings to strings")
def _validate_versions(versions: dict[str, str]) -> None:
_validate_metadata(versions, REQUIRED_VERSIONS, "versions")
for name in REQUIRED_VERSIONS:
value = versions[name]
if (
not value.strip()
or value in {".", ".."}
or "/" in value
or "\\" in value
or "\x00" in value
):
raise ValueError(f"version {name} must be a safe path component")
if not re.fullmatch(
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", value
):
raise ValueError(
f"version {name} must be a release version MAJOR.MINOR.PATCH"
)
release_version = versions["archive_version"]
for name in (
"homebrew_version",
"base_version",
"logical_version",
"python_version",
):
if name in versions and versions[name] != release_version:
raise ValueError(
f"version {name} must match release version {release_version}"
)
def _validate_source(source: dict[str, str]) -> None:
_validate_metadata(source, REQUIRED_SOURCE, "source")
for name in REQUIRED_SOURCE:
if not source[name].strip():
raise ValueError(f"source {name} must be nonempty")
def _input_files(input_dir: Path) -> list[Path]:
if not input_dir.is_dir() or input_dir.is_symlink():
raise ValueError(f"input directory is not a real directory: {input_dir}")
files: list[Path] = []
for directory, directories, names in os.walk(input_dir, followlinks=False):
directory_path = Path(directory)
for child in directories:
path = directory_path / child
if path.is_symlink():
raise ValueError(f"symbolic link input is not allowed: {path}")
for name in names:
path = directory_path / name
if path.is_symlink():
raise ValueError(f"symbolic link input is not allowed: {path}")
if not path.is_file():
raise ValueError(f"undeclared file type: {path}")
if path.stat().st_size == 0:
raise ValueError(f"empty file input is not allowed: {path}")
files.append(path)
return sorted(files, key=lambda path: path.relative_to(input_dir).as_posix())
def _artifact_description(
input_dir: Path, source_path: Path, versions: dict[str, str]
) -> tuple[Path, dict[str, object]]:
relative_path = source_path.relative_to(input_dir)
parts = relative_path.parts
filename = source_path.name
if len(parts) >= 2 and parts[0] == "deb" and filename.endswith(".deb"):
match = re.search(r"_(amd64|arm64)\.deb$", filename)
if not match:
raise ValueError(f"unsupported DEB architecture: {filename}")
architecture = match.group(1)
platform = f"ubuntu22.04-{architecture}"
target = Path("deb") / platform / filename
return target, {
"family": "deb",
"platform": platform,
"targetRepository": "tsfile-debian",
"targetPath": f"pool/release/{platform}/{filename}",
"properties": {
**DEB_PROPERTIES,
"deb.architecture": [architecture],
},
}
if len(parts) >= 2 and parts[0] == "rpm" and filename.endswith(".rpm"):
match = re.search(r"\.(x86_64|aarch64)\.rpm$", filename)
if not match:
raise ValueError(f"unsupported RPM architecture: {filename}")
architecture = match.group(1)
platform = f"almalinux9-{architecture}"
target = Path("rpm") / platform / filename
return target, {
"family": "rpm",
"platform": platform,
"targetRepository": "tsfile-rpm",
"targetPath": f"release/el9/{architecture}/{filename}",
"properties": {},
}
if (
len(parts) == 3
and parts[:2] == ("homebrew", "Formula")
and filename == "tsfile.rb"
):
target = Path(*parts)
return target, {
"family": "homebrew",
"platform": "homebrew",
"targetRepository": "tsfile",
"targetPath": f"homebrew/release/versions/{versions['homebrew_version']}/{target.as_posix()[len('homebrew/'):]}",
"properties": {},
}
if (
len(parts) == 3
and parts[:2] == ("homebrew", "bottles")
and (filename.endswith(".tar.gz") or filename.endswith(".bottle.json"))
):
target = Path(*parts)
return target, {
"family": "homebrew",
"platform": "homebrew",
"targetRepository": "tsfile",
"targetPath": f"homebrew/release/versions/{versions['homebrew_version']}/{target.as_posix()[len('homebrew/'):]}",
"properties": {},
}
if len(parts) == 3 and parts[0] == "sdk" and filename.endswith(".tar.gz"):
platform = parts[1]
if platform not in SDK_PLATFORMS:
raise ValueError(f"unsupported SDK platform: {platform}")
target = Path("sdk") / platform / filename
return target, {
"family": "sdk",
"platform": platform,
"targetRepository": "tsfile",
"targetPath": (
f"sdk/release/versions/{versions['archive_version']}/{platform}/{filename}"
),
"properties": {},
}
if len(parts) == 3 and parts[0] == "python" and filename.endswith(".whl"):
platform = parts[1]
if platform not in PYTHON_PLATFORMS:
raise ValueError(f"unsupported Python wheel platform: {platform}")
target = Path("python/wheels") / platform / filename
return target, {
"family": "python",
"platform": platform,
**_wheel_metadata(filename, platform),
}
if len(parts) >= 2 and parts[0] == "windows" and filename.endswith(".zip"):
match = re.search(r"-windows-(x86_64|arm64)\.zip$", filename)
if not match:
raise ValueError(f"unsupported Windows ZIP architecture: {filename}")
platform = f"windows-msvc-{match.group(1)}"
target = Path("windows") / filename
return target, {
"family": "windows",
"platform": platform,
"targetRepository": "tsfile",
"targetPath": f"windows/release/versions/{versions['archive_version']}/{filename}",
"properties": {},
}
raise ValueError(f"undeclared file type: {relative_path.as_posix()}")
def _require_complete_families(
planned: list[tuple[Path, Path, dict[str, object]]],
) -> None:
platforms_by_family = {
family: {
metadata["platform"]
for _, _, metadata in planned
if metadata["family"] == family
}
for family in ("deb", "rpm", "windows")
}
for family, required_platforms in (
("deb", DEB_PLATFORMS),
("rpm", RPM_PLATFORMS),
("windows", WINDOWS_PLATFORMS),
):
missing_platforms = required_platforms - platforms_by_family[family]
if missing_platforms:
raise ValueError(
f"missing {family.upper()} inputs for: "
+ ", ".join(sorted(missing_platforms))
)
sdk_platforms = {
metadata["platform"]
for _, _, metadata in planned
if metadata["family"] == "sdk"
}
missing_sdk_platforms = SDK_PLATFORMS - sdk_platforms
if missing_sdk_platforms:
raise ValueError(
"missing SDK inputs for: " + ", ".join(sorted(missing_sdk_platforms))
)
python_platforms = {
metadata["platform"]
for _, _, metadata in planned
if metadata["family"] == "python"
}
missing_python_platforms = PYTHON_PLATFORMS - python_platforms
if missing_python_platforms:
raise ValueError(
"missing Python wheel inputs for: "
+ ", ".join(sorted(missing_python_platforms))
)
wheel_versions: set[str] = set()
for platform in sorted(PYTHON_PLATFORMS):
wheels = [
metadata
for _, _, metadata in planned
if metadata["family"] == "python" and metadata["platform"] == platform
]
tags = [wheel["pythonTag"] for wheel in wheels]
missing = PYTHON_TAGS - set(tags)
if missing:
raise ValueError(
f"missing Python wheel ABIs for {platform}: "
+ ", ".join(sorted(missing))
)
if len(tags) != len(set(tags)):
raise ValueError(f"duplicate Python wheel ABI for {platform}")
wheel_versions.update(wheel["wheelVersion"] for wheel in wheels)
if len(wheel_versions) != 1:
raise ValueError("Python wheels must all have the same version")
homebrew_paths = {
target.as_posix()
for _, target, metadata in planned
if metadata["family"] == "homebrew"
}
if "homebrew/Formula/tsfile.rb" not in homebrew_paths:
raise ValueError("missing Homebrew Formula input")
if not any(path.endswith(".tar.gz") for path in homebrew_paths):
raise ValueError("missing Homebrew Bottle input")
if not any(path.endswith(".bottle.json") for path in homebrew_paths):
raise ValueError("missing Homebrew Bottle metadata input")
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _artifact_paths(output_dir: Path) -> set[Path]:
paths: set[Path] = set()
for path in output_dir.rglob("*"):
if path.is_symlink():
raise ValueError(f"symbolic link output is not allowed: {path}")
if path.is_file() and path.name not in {"SHA256SUMS", "manifest.json"}:
paths.add(path.relative_to(output_dir))
return paths
def verify_bundle(output_dir: Path) -> None:
"""Verify manifest hashes, sizes, and path-sorted checksums in a bundle."""
output_dir = Path(output_dir)
if not output_dir.is_dir() or output_dir.is_symlink():
raise ValueError(f"bundle directory is not a real directory: {output_dir}")
manifest_path = output_dir / "manifest.json"
checksums_path = output_dir / "SHA256SUMS"
if (
not manifest_path.is_file()
or manifest_path.is_symlink()
or not checksums_path.is_file()
or checksums_path.is_symlink()
):
raise ValueError("bundle must contain real manifest.json and SHA256SUMS files")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
artifacts = manifest.get("artifacts") if isinstance(manifest, dict) else None
if not isinstance(artifacts, list):
raise ValueError("manifest artifacts must be a list")
paths: set[Path] = set()
for artifact in artifacts:
if not isinstance(artifact, dict):
raise ValueError("manifest artifacts must be objects")
relative_text = artifact.get("path")
if not isinstance(relative_text, str):
raise ValueError("manifest artifact path must be a string")
relative = Path(relative_text)
if (
relative.is_absolute()
or relative.as_posix() != relative_text
or ".." in relative.parts
):
raise ValueError(f"unsafe manifest path: {relative_text}")
if relative in paths:
raise ValueError(f"duplicate manifest path: {relative_text}")
paths.add(relative)
path = output_dir / relative
if not path.is_file() or path.is_symlink():
raise ValueError(f"missing artifact: {relative_text}")
sha256 = artifact.get("sha256")
if not isinstance(sha256, str) or re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
raise ValueError(f"invalid artifact SHA-256: {relative_text}")
if _sha256(path) != sha256:
raise ValueError(f"hash mismatch: {relative_text}")
if path.stat().st_size != artifact.get("size"):
raise ValueError(f"size mismatch: {relative_text}")
actual_checksums = checksums_path.read_text(encoding="utf-8").splitlines()
expected_checksums = [
f"{artifact['sha256']} {artifact['path']}"
for artifact in sorted(artifacts, key=lambda artifact: artifact["path"])
]
if actual_checksums != expected_checksums:
raise ValueError("SHA256SUMS does not match path-sorted manifest artifacts")
if _artifact_paths(output_dir) != paths:
raise ValueError("bundle files do not match manifest artifacts")
def assemble(
input_dir: Path, output_dir: Path, versions: dict[str, str], source: dict[str, str]
) -> dict[str, object]:
"""Copy approved artifacts and return their deterministic publication manifest."""
_validate_versions(versions)
_validate_source(source)
input_dir = Path(input_dir)
output_dir = Path(output_dir)
files = _input_files(input_dir)
planned: list[tuple[Path, Path, dict[str, object]]] = []
target_paths: set[Path] = set()
for source_path in files:
target, metadata = _artifact_description(input_dir, source_path, versions)
if target in target_paths:
raise ValueError(f"duplicate target path: {target.as_posix()}")
target_paths.add(target)
planned.append((source_path, target, metadata))
_require_complete_families(planned)
release_version = versions["archive_version"]
for _, _, metadata in planned:
if (
metadata["family"] == "python"
and metadata["wheelVersion"] != release_version
):
raise ValueError(
f"Python wheel version must match release version {release_version}"
)
if output_dir.is_symlink():
raise ValueError(f"output directory must not be a symbolic link: {output_dir}")
if output_dir.exists() and any(output_dir.iterdir()):
raise ValueError(f"output directory must be empty: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
artifacts: list[dict[str, object]] = []
for source_path, target, metadata in sorted(
planned, key=lambda item: item[1].as_posix()
):
destination = output_dir / target
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_path, destination)
artifact = {
**metadata,
"filename": destination.name,
"path": target.as_posix(),
"size": destination.stat().st_size,
"sha256": _sha256(destination),
}
artifacts.append(artifact)
checksums = "".join(
f"{artifact['sha256']} {artifact['path']}\n" for artifact in artifacts
)
(output_dir / "SHA256SUMS").write_text(checksums, encoding="utf-8")
manifest: dict[str, object] = {
"artifacts": artifacts,
"source": source,
"versions": versions,
}
(output_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
return manifest
def _argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path)
parser.add_argument("--output-dir", type=Path)
parser.add_argument("--versions-json", type=Path)
parser.add_argument("--source-repository")
parser.add_argument("--source-commit")
parser.add_argument("--verify-bundle", type=Path)
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""Assemble a bundle from an Actions artifact download directory."""
arguments = _argument_parser().parse_args(argv)
if arguments.verify_bundle is not None:
if any(
value is not None
for value in (
arguments.input_dir,
arguments.output_dir,
arguments.versions_json,
arguments.source_repository,
arguments.source_commit,
)
):
raise ValueError(
"--verify-bundle cannot be combined with assembly arguments"
)
verify_bundle(arguments.verify_bundle)
return 0
missing = [
name
for name, value in (
("--input-dir", arguments.input_dir),
("--output-dir", arguments.output_dir),
("--versions-json", arguments.versions_json),
("--source-repository", arguments.source_repository),
("--source-commit", arguments.source_commit),
)
if value is None
]
if missing:
raise ValueError(f"missing assembly arguments: {', '.join(missing)}")
versions = json.loads(arguments.versions_json.read_text(encoding="utf-8"))
assemble(
arguments.input_dir,
arguments.output_dir,
versions,
{"repository": arguments.source_repository, "commit": arguments.source_commit},
)
return 0
if __name__ == "__main__":
raise SystemExit(main())