Updated telemetry to support game dumps.
This commit is contained in:
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
import fnmatch
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -20,10 +21,14 @@ import tempfile
|
||||
from typing import BinaryIO, Mapping, Sequence
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from scripts.repository_paths import (
|
||||
ARTIFACTS_ROOT,
|
||||
PROJECT_ROOT,
|
||||
RELEASE_ARTIFACTS_ROOT,
|
||||
ensure_project_imports,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
ensure_project_imports()
|
||||
CONFIG_PATH = PROJECT_ROOT / "release/config.json"
|
||||
READ_SIZE = 256 * 1024
|
||||
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
|
||||
@@ -42,6 +47,9 @@ _PROJECT_VERSION_PATTERN = re.compile(
|
||||
r'^version = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$',
|
||||
re.MULTILINE,
|
||||
)
|
||||
_LEGACY_VERSION_PATTERN = re.compile(
|
||||
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z"
|
||||
)
|
||||
|
||||
|
||||
class ReleaseToolError(RuntimeError):
|
||||
@@ -202,8 +210,11 @@ def build_release(
|
||||
_validate_repository_url(repository)
|
||||
release_notes = _read_release_notes(release_notes_file)
|
||||
output = _safe_output_directory(output_directory)
|
||||
if signing_key is not None and signing_key.expanduser().resolve().is_relative_to(output):
|
||||
raise ReleaseToolError("the private signing key cannot be stored in release output")
|
||||
if signing_key is not None:
|
||||
signing_key = _safe_signing_key_path(
|
||||
signing_key,
|
||||
output_directory=output,
|
||||
)
|
||||
_clean_output_directory(output)
|
||||
archive_prefix = _required_string(config, "archive_prefix")
|
||||
archive_name = f"{archive_prefix}-{version}.tar.gz"
|
||||
@@ -264,7 +275,10 @@ def build_release(
|
||||
verify_signature(
|
||||
manifest,
|
||||
signature,
|
||||
PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
|
||||
_payload_source_for_destination(
|
||||
config,
|
||||
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
||||
),
|
||||
)
|
||||
artifacts.append(signature)
|
||||
elif require_signature:
|
||||
@@ -272,6 +286,112 @@ def build_release(
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def build_legacy_launcher_update(
|
||||
*,
|
||||
version: str,
|
||||
output_directory: Path,
|
||||
repository_url: str,
|
||||
minimum_updater_version: str,
|
||||
signing_key: Path | None,
|
||||
require_signature: bool = False,
|
||||
source_date_epoch: int,
|
||||
) -> tuple[Path, ...]:
|
||||
"""Build the schema-1 update artifacts consumed by deployed launchers.
|
||||
|
||||
The names, wrapped archive layout, minimal ``release.json``, and ``stable.json``
|
||||
shape are compatibility contracts. Payload selection and archive serialization
|
||||
live here so the historical ``tools/build_launcher_update.py`` entry point can
|
||||
remain a forwarding wrapper.
|
||||
"""
|
||||
|
||||
_validate_legacy_version(version, "release version")
|
||||
_validate_legacy_version(minimum_updater_version, "minimum updater version")
|
||||
config = load_config()
|
||||
current_version = source_version(config)
|
||||
if current_version != version:
|
||||
raise ReleaseToolError(
|
||||
f"requested version {version} does not match ptlinux.__version__ "
|
||||
f"{current_version}"
|
||||
)
|
||||
_validate_repository_url(repository_url)
|
||||
if require_signature and signing_key is None:
|
||||
raise ReleaseToolError("a signing key is required for a production release")
|
||||
if source_date_epoch < 0:
|
||||
raise ReleaseToolError("source date epoch cannot be negative")
|
||||
|
||||
output = output_directory.expanduser().resolve()
|
||||
if signing_key is not None:
|
||||
signing_key = _safe_signing_key_path(
|
||||
signing_key,
|
||||
output_directory=output,
|
||||
)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive_root = (
|
||||
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
|
||||
)
|
||||
archive = output / f"{archive_root}.tar.gz"
|
||||
archive_temporary = output / f".{archive.name}.{os.getpid()}.tmp"
|
||||
archive_temporary.unlink(missing_ok=True)
|
||||
try:
|
||||
_write_legacy_launcher_archive(
|
||||
archive_temporary,
|
||||
archive_root=archive_root,
|
||||
version=version,
|
||||
mtime=source_date_epoch,
|
||||
payload=collect_payload(config),
|
||||
)
|
||||
archive_temporary.chmod(0o644)
|
||||
archive_temporary.replace(archive)
|
||||
except BaseException:
|
||||
archive_temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
digest = sha256_file(archive)
|
||||
checksum = archive.with_suffix(archive.suffix + ".sha256")
|
||||
_write_atomic(checksum, f"{digest} {archive.name}\n".encode("ascii"))
|
||||
repository = repository_url.rstrip("/")
|
||||
manifest = {
|
||||
"archive_sha256": digest,
|
||||
"archive_size": archive.stat().st_size,
|
||||
"archive_url": f"{repository}/releases/download/v{version}/{archive.name}",
|
||||
"channel": "stable",
|
||||
"minimum_updater_version": minimum_updater_version,
|
||||
"notes_url": f"{repository}/releases/tag/v{version}",
|
||||
"published_at": _timestamp_from_epoch(source_date_epoch),
|
||||
"schema": 1,
|
||||
"version": version,
|
||||
}
|
||||
manifest_path = output / "stable.json"
|
||||
manifest_bytes = (
|
||||
json.dumps(manifest, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
|
||||
).encode("utf-8")
|
||||
_write_atomic(manifest_path, manifest_bytes)
|
||||
signature_path = output / "stable.json.sig"
|
||||
artifacts: list[Path] = [archive, checksum, manifest_path]
|
||||
if signing_key is not None:
|
||||
sign_file(manifest_path, signature_path, signing_key)
|
||||
public_key = _payload_source_for_destination(
|
||||
config,
|
||||
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
||||
)
|
||||
try:
|
||||
verify_signature(manifest_path, signature_path, public_key)
|
||||
except ReleaseToolError as exc:
|
||||
signature_path.unlink(missing_ok=True)
|
||||
raise ReleaseToolError(
|
||||
"the signing key does not match ptlinux/launcher-update-public.pem"
|
||||
) from exc
|
||||
artifacts.append(signature_path)
|
||||
else:
|
||||
signature_path.unlink(missing_ok=True)
|
||||
print(
|
||||
"Warning: stable.json was not signed; set PTLINUX_SIGNING_KEY for a "
|
||||
"publishable release.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def stage_release(
|
||||
destination: Path,
|
||||
config: Mapping[str, object],
|
||||
@@ -307,6 +427,38 @@ def stage_release(
|
||||
payload.append(
|
||||
PayloadFile(PurePosixPath("release.json"), None, release_metadata, 0o644)
|
||||
)
|
||||
_stage_payload_files(destination, payload)
|
||||
_require_staged_files(destination, config)
|
||||
|
||||
|
||||
def stage_runtime_payload(
|
||||
destination: Path,
|
||||
config: Mapping[str, object] | None = None,
|
||||
*,
|
||||
source_root: Path | None = None,
|
||||
) -> None:
|
||||
"""Stage the runtime files shared by installers and launcher updates.
|
||||
|
||||
`release.json` is generated by the caller because bootstrap installers and
|
||||
signed launcher updates attach different source metadata to that file.
|
||||
"""
|
||||
|
||||
release_config = load_config() if config is None else config
|
||||
_stage_payload_files(
|
||||
destination,
|
||||
collect_payload(release_config, source_root=source_root),
|
||||
)
|
||||
_require_staged_files(
|
||||
destination,
|
||||
release_config,
|
||||
allowed_missing={"release.json"},
|
||||
)
|
||||
|
||||
|
||||
def _stage_payload_files(
|
||||
destination: Path,
|
||||
payload: Sequence[PayloadFile],
|
||||
) -> None:
|
||||
seen: set[PurePosixPath] = set()
|
||||
for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()):
|
||||
if item.relative_path in seen:
|
||||
@@ -324,12 +476,27 @@ def stage_release(
|
||||
for directory in (destination, *destination.rglob("*")):
|
||||
if directory.is_dir():
|
||||
directory.chmod(0o755)
|
||||
|
||||
|
||||
def _require_staged_files(
|
||||
destination: Path,
|
||||
config: Mapping[str, object],
|
||||
*,
|
||||
allowed_missing: set[str] | None = None,
|
||||
) -> None:
|
||||
missing_allowed = set() if allowed_missing is None else allowed_missing
|
||||
for required in _string_list(config.get("required_files"), "required_files"):
|
||||
if required in missing_allowed:
|
||||
continue
|
||||
if not (destination / required).is_file():
|
||||
raise ReleaseToolError(f"staging tree is missing required file: {required}")
|
||||
|
||||
|
||||
def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
|
||||
def collect_payload(
|
||||
config: Mapping[str, object],
|
||||
*,
|
||||
source_root: Path | None = None,
|
||||
) -> list[PayloadFile]:
|
||||
payload_config = config.get("payload")
|
||||
if not isinstance(payload_config, dict):
|
||||
raise ReleaseToolError("release payload configuration must be an object")
|
||||
@@ -341,18 +508,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
|
||||
if not isinstance(tree, dict):
|
||||
raise ReleaseToolError("payload python tree must be an object")
|
||||
source_name = _required_string(tree, "source")
|
||||
source_root = PROJECT_ROOT / source_name
|
||||
if not source_root.is_dir() or source_root.is_symlink():
|
||||
source_relative = _safe_relative_path(source_name)
|
||||
destination_name = tree.get("destination", source_name)
|
||||
if not isinstance(destination_name, str) or not destination_name:
|
||||
raise ReleaseToolError("payload python tree destination must be a string")
|
||||
destination_relative = _safe_relative_path(destination_name)
|
||||
tree_root = _payload_source_path(
|
||||
source_relative,
|
||||
source_root,
|
||||
packaged_relative=destination_relative,
|
||||
)
|
||||
if not tree_root.is_dir() or tree_root.is_symlink():
|
||||
raise ReleaseToolError(f"payload source tree is invalid: {source_name}")
|
||||
exclusions = set(_string_list(tree.get("exclude", []), "payload exclude"))
|
||||
for source in sorted(source_root.rglob("*.py")):
|
||||
relative_in_tree = source.relative_to(source_root).as_posix()
|
||||
for source in sorted(tree_root.rglob("*.py")):
|
||||
relative_in_tree = source.relative_to(tree_root).as_posix()
|
||||
if relative_in_tree in exclusions:
|
||||
continue
|
||||
_validate_source_file(source)
|
||||
result.append(
|
||||
PayloadFile(
|
||||
PurePosixPath(source_name) / PurePosixPath(relative_in_tree),
|
||||
destination_relative / PurePosixPath(relative_in_tree),
|
||||
source,
|
||||
None,
|
||||
0o644,
|
||||
@@ -364,9 +540,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
|
||||
for file_config in configured_files:
|
||||
if not isinstance(file_config, dict):
|
||||
raise ReleaseToolError("payload file entry must be an object")
|
||||
path_text = _required_string(file_config, "path")
|
||||
relative = _safe_relative_path(path_text)
|
||||
source = PROJECT_ROOT.joinpath(*relative.parts)
|
||||
legacy_path = file_config.get("path")
|
||||
if legacy_path is not None:
|
||||
if "source" in file_config or "destination" in file_config:
|
||||
raise ReleaseToolError(
|
||||
"payload file entry cannot combine path with source or destination"
|
||||
)
|
||||
path_text = _required_string(file_config, "path")
|
||||
source_relative = _safe_relative_path(path_text)
|
||||
destination_relative = source_relative
|
||||
else:
|
||||
source_relative = _safe_relative_path(
|
||||
_required_string(file_config, "source")
|
||||
)
|
||||
destination_relative = _safe_relative_path(
|
||||
_required_string(file_config, "destination")
|
||||
)
|
||||
source = _payload_source_path(
|
||||
source_relative,
|
||||
source_root,
|
||||
packaged_relative=destination_relative,
|
||||
)
|
||||
_validate_source_file(source)
|
||||
mode_text = _required_string(file_config, "mode")
|
||||
try:
|
||||
@@ -375,10 +569,29 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
|
||||
raise ReleaseToolError(f"invalid payload mode {mode_text!r}") from exc
|
||||
if mode not in {0o644, 0o755}:
|
||||
raise ReleaseToolError(f"unsupported payload mode: {mode_text}")
|
||||
result.append(PayloadFile(relative, source, None, mode))
|
||||
result.append(PayloadFile(destination_relative, source, None, mode))
|
||||
return result
|
||||
|
||||
|
||||
def _payload_source_path(
|
||||
relative: PurePosixPath,
|
||||
source_root: Path | None,
|
||||
*,
|
||||
packaged_relative: PurePosixPath | None = None,
|
||||
) -> Path:
|
||||
if source_root is None:
|
||||
return PROJECT_ROOT.joinpath(*relative.parts)
|
||||
candidates = [source_root.joinpath(*relative.parts)]
|
||||
if relative.parts[:1] == ("src",):
|
||||
candidates.append(source_root.joinpath(*relative.parts[1:]))
|
||||
if packaged_relative is not None:
|
||||
candidates.append(source_root.joinpath(*packaged_relative.parts))
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def generate_manifest(
|
||||
destination: Path,
|
||||
*,
|
||||
@@ -543,7 +756,7 @@ def verify_release(
|
||||
if not (extracted / required).is_file():
|
||||
raise ReleaseToolError(f"release archive is missing required file: {required}")
|
||||
packaged_version = _version_from_source_file(
|
||||
extracted / _required_string(config, "version_source")
|
||||
extracted / _packaged_version_path(config)
|
||||
)
|
||||
if packaged_version != version:
|
||||
raise ReleaseToolError(
|
||||
@@ -568,16 +781,57 @@ def verify_release(
|
||||
verify_signature(
|
||||
manifest_path,
|
||||
signature,
|
||||
public_key or PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
|
||||
public_key or _payload_source_for_destination(
|
||||
config,
|
||||
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
||||
),
|
||||
)
|
||||
elif require_signature:
|
||||
raise ReleaseToolError("manifest signature is required but missing")
|
||||
|
||||
|
||||
def _safe_signing_key_path(
|
||||
private_key: Path,
|
||||
*,
|
||||
output_directory: Path | None = None,
|
||||
) -> Path:
|
||||
"""Validate that a private key is external, private, and a regular file."""
|
||||
|
||||
candidate = private_key.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise ReleaseToolError(
|
||||
f"release signing key cannot be a symbolic link: {candidate}"
|
||||
)
|
||||
resolved = candidate.resolve()
|
||||
location_pairs = (
|
||||
(candidate, PROJECT_ROOT.absolute()),
|
||||
(resolved, PROJECT_ROOT.resolve()),
|
||||
(candidate, ARTIFACTS_ROOT.absolute()),
|
||||
(resolved, ARTIFACTS_ROOT.resolve()),
|
||||
)
|
||||
if any(path == root or path.is_relative_to(root) for path, root in location_pairs):
|
||||
raise ReleaseToolError(
|
||||
"release signing key must be outside the repository and .artifacts: "
|
||||
f"{candidate}"
|
||||
)
|
||||
if output_directory is not None:
|
||||
output = output_directory.expanduser().resolve()
|
||||
if resolved == output or resolved.is_relative_to(output):
|
||||
raise ReleaseToolError(
|
||||
f"release signing key must be outside release output: {candidate}"
|
||||
)
|
||||
if not resolved.is_file():
|
||||
raise ReleaseToolError(f"release signing key is missing: {candidate}")
|
||||
if resolved.stat().st_mode & 0o077:
|
||||
raise ReleaseToolError(
|
||||
"release signing key must not be accessible by group or other users: "
|
||||
f"{candidate}"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def sign_file(source: Path, signature: Path, private_key: Path) -> None:
|
||||
key = private_key.expanduser().resolve()
|
||||
if not key.is_file() or key.is_symlink():
|
||||
raise ReleaseToolError(f"manifest signing key is missing: {key}")
|
||||
key = _safe_signing_key_path(private_key)
|
||||
openssl = shutil.which("openssl")
|
||||
if openssl is None:
|
||||
raise ReleaseToolError("OpenSSL is required to sign release manifests")
|
||||
@@ -730,6 +984,65 @@ def _write_deterministic_archive(
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _write_legacy_launcher_archive(
|
||||
destination: Path,
|
||||
*,
|
||||
archive_root: str,
|
||||
version: str,
|
||||
mtime: int,
|
||||
payload: Sequence[PayloadFile],
|
||||
) -> None:
|
||||
"""Write the original wrapped launcher archive byte-for-byte deterministically."""
|
||||
|
||||
metadata = (
|
||||
json.dumps({"schema": 1, "version": version}, indent=2, sort_keys=True) + "\n"
|
||||
).encode("utf-8")
|
||||
with destination.open("xb") as raw:
|
||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=mtime) as compressed:
|
||||
with tarfile.open(
|
||||
fileobj=compressed,
|
||||
mode="w",
|
||||
format=tarfile.PAX_FORMAT,
|
||||
) as bundle:
|
||||
for directory in (
|
||||
archive_root,
|
||||
f"{archive_root}/ptdiag",
|
||||
f"{archive_root}/ptlinux",
|
||||
f"{archive_root}/ptlinux/native",
|
||||
):
|
||||
information = _tar_info(directory, mtime, 0o755)
|
||||
information.type = b"5"
|
||||
bundle.addfile(information)
|
||||
with io.BytesIO(metadata) as handle:
|
||||
_add_tar_file(
|
||||
bundle,
|
||||
information_name=f"{archive_root}/release.json",
|
||||
handle=handle,
|
||||
size=len(metadata),
|
||||
mtime=mtime,
|
||||
mode=0o644,
|
||||
)
|
||||
for item in sorted(
|
||||
payload,
|
||||
key=lambda entry: entry.relative_path.as_posix(),
|
||||
):
|
||||
if item.source is None:
|
||||
raise ReleaseToolError(
|
||||
f"legacy payload has no source: {item.relative_path}"
|
||||
)
|
||||
with item.source.open("rb") as handle:
|
||||
_add_tar_file(
|
||||
bundle,
|
||||
information_name=(
|
||||
f"{archive_root}/{item.relative_path.as_posix()}"
|
||||
),
|
||||
handle=handle,
|
||||
size=item.source.stat().st_size,
|
||||
mtime=mtime,
|
||||
mode=item.mode,
|
||||
)
|
||||
|
||||
|
||||
def _tar_info(name: str, mtime: int, mode: int) -> tarfile.TarInfo:
|
||||
information = tarfile.TarInfo(name)
|
||||
information.uid = 0
|
||||
@@ -1005,12 +1318,13 @@ def _safe_output_directory(path: Path) -> Path:
|
||||
if expanded.is_symlink():
|
||||
raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}")
|
||||
resolved = expanded.resolve()
|
||||
project_dist_path = PROJECT_ROOT / "dist"
|
||||
if project_dist_path.is_symlink():
|
||||
project_release_path = RELEASE_ARTIFACTS_ROOT
|
||||
if project_release_path.is_symlink():
|
||||
raise ReleaseToolError(
|
||||
f"repository dist directory cannot be a symbolic link: {project_dist_path}"
|
||||
"repository release-artifact directory cannot be a symbolic link: "
|
||||
f"{project_release_path}"
|
||||
)
|
||||
project_dist = project_dist_path.resolve()
|
||||
project_releases = project_release_path.resolve()
|
||||
temporary_roots = (
|
||||
Path(tempfile.gettempdir()).resolve(),
|
||||
Path("/var/tmp").resolve(),
|
||||
@@ -1023,12 +1337,16 @@ def _safe_output_directory(path: Path) -> Path:
|
||||
}
|
||||
if resolved in unsafe or resolved in PROJECT_ROOT.resolve().parents:
|
||||
raise ReleaseToolError(f"refusing unsafe output directory: {resolved}")
|
||||
if resolved == project_dist or resolved.is_relative_to(project_dist):
|
||||
if resolved == project_releases or resolved.is_relative_to(project_releases):
|
||||
return resolved
|
||||
if resolved.is_relative_to(PROJECT_ROOT.resolve()):
|
||||
raise ReleaseToolError(
|
||||
"release output inside the repository must be below .artifacts/releases"
|
||||
)
|
||||
if any(resolved.is_relative_to(root) for root in temporary_roots):
|
||||
return resolved
|
||||
raise ReleaseToolError(
|
||||
"release output must be the repository dist directory or a temporary directory"
|
||||
"release output must be below .artifacts/releases or a temporary directory"
|
||||
)
|
||||
|
||||
|
||||
@@ -1111,6 +1429,21 @@ def _validate_repository_url(value: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_legacy_version(value: str, label: str) -> None:
|
||||
if _LEGACY_VERSION_PATTERN.fullmatch(value) is None:
|
||||
raise ReleaseToolError(f"{label} must use X.Y.Z format: {value!r}")
|
||||
|
||||
|
||||
def _payload_source_for_destination(
|
||||
config: Mapping[str, object],
|
||||
destination: PurePosixPath,
|
||||
) -> Path:
|
||||
for item in collect_payload(config):
|
||||
if item.relative_path == destination and item.source is not None:
|
||||
return item.source
|
||||
raise ReleaseToolError(f"release payload is missing {destination.as_posix()}")
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(
|
||||
@@ -1166,7 +1499,7 @@ def _set_staged_source_version(
|
||||
config: Mapping[str, object],
|
||||
version: str,
|
||||
) -> None:
|
||||
path = staging / _required_string(config, "version_source")
|
||||
path = staging / _packaged_version_path(config)
|
||||
try:
|
||||
contents = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
@@ -1212,6 +1545,17 @@ def _write_atomic(path: Path, data: bytes) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _packaged_version_path(config: Mapping[str, object]) -> PurePosixPath:
|
||||
value = config.get("packaged_version_path")
|
||||
if value is None:
|
||||
# Older repository fixtures used one path because source and archive
|
||||
# layouts were identical.
|
||||
value = _required_string(config, "version_source")
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ReleaseToolError("packaged_version_path must be a non-empty string")
|
||||
return _safe_relative_path(value)
|
||||
|
||||
|
||||
def _git_output(*arguments: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
||||
Reference in New Issue
Block a user