Harden release source execution
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
@@ -165,16 +166,50 @@ def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
|
||||
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
["/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args],
|
||||
cwd=path,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
env=sanitized_git_environment(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||
return subprocess.CompletedProcess(["/usr/bin/git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||
|
||||
|
||||
def sanitized_git_environment(
|
||||
source: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Keep only deliberate process/auth inputs and neutralize Git redirection."""
|
||||
|
||||
environment = os.environ if source is None else source
|
||||
result = {
|
||||
key: environment[key]
|
||||
for key in (
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"SSH_AUTH_SOCK",
|
||||
)
|
||||
if environment.get(key)
|
||||
}
|
||||
result.update(
|
||||
{
|
||||
"GIT_CONFIG_GLOBAL": os.devnull,
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_CONFIG_COUNT": "0",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_PAGER": "cat",
|
||||
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def git_error(result: subprocess.CompletedProcess[str]) -> str:
|
||||
|
||||
@@ -44,7 +44,15 @@ def module_directory_payloads(
|
||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||
) -> tuple[tuple[Path, dict[str, Any]], ...]:
|
||||
channel = validate_release_channel(channel)
|
||||
generated_at = json_datetime(datetime.now(tz=UTC))
|
||||
# Publication artifacts must be reproducible from the signed catalog so an
|
||||
# interrupted push can later be reconciled against the immutable commit.
|
||||
# Older/ad-hoc callers without a catalog timestamp retain the old fallback.
|
||||
catalog_generated_at = catalog_payload.get("generated_at")
|
||||
generated_at = (
|
||||
catalog_generated_at
|
||||
if isinstance(catalog_generated_at, str) and catalog_generated_at
|
||||
else json_datetime(datetime.now(tz=UTC))
|
||||
)
|
||||
modules = module_rows(catalog_payload)
|
||||
compatibility = compatibility_rows(modules)
|
||||
signatures = signature_rows(catalog_payload, keyring_payload)
|
||||
|
||||
@@ -1024,11 +1024,18 @@ def publication_runtime_trust_issues() -> tuple[str, ...]:
|
||||
ancestor_issue = _operator_controlled_ancestor_issue(path)
|
||||
if ancestor_issue:
|
||||
return (f"{label}: {ancestor_issue}",)
|
||||
executable_issue = _trusted_runtime_executable_issue(
|
||||
Path(sys.executable), permitted_owners=runtime_owners
|
||||
)
|
||||
if executable_issue:
|
||||
return (executable_issue,)
|
||||
for executable, label in (
|
||||
(Path(sys.executable), "Python executable"),
|
||||
(Path("/usr/bin/git"), "Git executable"),
|
||||
(Path("/usr/bin/ssh"), "SSH executable"),
|
||||
):
|
||||
executable_issue = _trusted_runtime_executable_issue(
|
||||
executable,
|
||||
permitted_owners=runtime_owners,
|
||||
label=label,
|
||||
)
|
||||
if executable_issue:
|
||||
return (executable_issue,)
|
||||
required = (
|
||||
(META_ROOT, True, "meta repository root"),
|
||||
(META_ROOT / "repositories.json", False, "repository registry"),
|
||||
@@ -1093,32 +1100,32 @@ def _loaded_package_root(module: object, *, label: str) -> Path:
|
||||
|
||||
|
||||
def _trusted_runtime_executable_issue(
|
||||
path: Path, *, permitted_owners: set[int]
|
||||
path: Path, *, permitted_owners: set[int], label: str = "Python executable"
|
||||
) -> str | None:
|
||||
executable = path.absolute()
|
||||
ancestor_issue = _operator_controlled_ancestor_issue(executable.parent)
|
||||
if ancestor_issue:
|
||||
return f"Python executable: {ancestor_issue}"
|
||||
return f"{label}: {ancestor_issue}"
|
||||
try:
|
||||
link_metadata = executable.lstat()
|
||||
except OSError:
|
||||
return f"Python executable cannot be inspected safely: {executable}"
|
||||
return f"{label} cannot be inspected safely: {executable}"
|
||||
if stat.S_ISLNK(link_metadata.st_mode):
|
||||
if link_metadata.st_uid not in permitted_owners:
|
||||
return f"Python executable symlink has an untrusted owner: {executable}"
|
||||
return f"{label} symlink has an untrusted owner: {executable}"
|
||||
try:
|
||||
target = executable.resolve(strict=True)
|
||||
except OSError:
|
||||
return f"Python executable symlink cannot be resolved safely: {executable}"
|
||||
return f"{label} symlink cannot be resolved safely: {executable}"
|
||||
else:
|
||||
target = executable
|
||||
ancestor_issue = _operator_controlled_ancestor_issue(target.parent)
|
||||
if ancestor_issue:
|
||||
return f"Python executable target: {ancestor_issue}"
|
||||
return f"{label} target: {ancestor_issue}"
|
||||
return _operator_control_issue(
|
||||
target,
|
||||
directory=False,
|
||||
label="Python executable target",
|
||||
label=f"{label} target",
|
||||
permitted_owners=permitted_owners,
|
||||
)
|
||||
|
||||
@@ -1866,7 +1873,9 @@ def _sanitized_git_environment(
|
||||
|
||||
source = base if base is not None else os.environ
|
||||
environment = {
|
||||
key: value for key, value in source.items() if not key.startswith("GIT_")
|
||||
key: source[key]
|
||||
for key in ("HOME", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK")
|
||||
if source.get(key)
|
||||
}
|
||||
environment.update(
|
||||
{
|
||||
@@ -1875,7 +1884,9 @@ def _sanitized_git_environment(
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
"GIT_PAGER": "cat",
|
||||
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
)
|
||||
if isolate_config:
|
||||
@@ -1911,7 +1922,7 @@ def _git_bytes(
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"/usr/bin/git",
|
||||
"-C",
|
||||
str(path),
|
||||
"-c",
|
||||
|
||||
2300
tools/release/govoplan_release/release_execution.py
Normal file
2300
tools/release/govoplan_release/release_execution.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .git_state import collect_repository_snapshot, git_text
|
||||
from .git_state import (
|
||||
collect_repository_snapshot,
|
||||
git_text,
|
||||
sanitized_git_environment,
|
||||
)
|
||||
from .model import RepositorySnapshot
|
||||
from .repository_push import command_text, compact_output
|
||||
from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues
|
||||
@@ -397,7 +401,16 @@ def manifest_shape_gate_issue(workspace: Path) -> str | None:
|
||||
"--workspace-root",
|
||||
str(workspace),
|
||||
)
|
||||
result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
result = run(
|
||||
command,
|
||||
cwd=META_ROOT,
|
||||
env={
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith("GIT_")
|
||||
}
|
||||
| {"PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return None
|
||||
detail = compact_output(result.stderr) or compact_output(result.stdout)
|
||||
@@ -463,16 +476,26 @@ def run(
|
||||
timeout: int = 120,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
effective_command = command
|
||||
effective_environment = env
|
||||
if command and Path(command[0]).name == "git":
|
||||
effective_command = (
|
||||
"/usr/bin/git",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
*command[1:],
|
||||
)
|
||||
effective_environment = sanitized_git_environment(env)
|
||||
try:
|
||||
return subprocess.run(
|
||||
command,
|
||||
effective_command,
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
env=effective_environment,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||
|
||||
@@ -33,10 +33,11 @@ from .catalog_entry_synthesis import (
|
||||
validate_initial_entry_closure,
|
||||
)
|
||||
from .contracts import collect_contracts
|
||||
from .git_state import collect_repository_snapshot
|
||||
from .git_state import collect_repository_snapshot, git_text
|
||||
from .model import CatalogEntryChange, SelectiveCatalogCandidate
|
||||
from .module_directory import write_module_directory
|
||||
from .source_provenance import (
|
||||
SourceTagProvenanceIssue,
|
||||
catalog_source_selection,
|
||||
selected_source_provenance,
|
||||
source_tag_provenance_issues,
|
||||
@@ -45,7 +46,12 @@ from .version_alignment import (
|
||||
selected_release_webui_bundle_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||
from .workspace import (
|
||||
load_repository_specs,
|
||||
resolve_repo_path,
|
||||
resolve_workspace_root,
|
||||
website_root,
|
||||
)
|
||||
|
||||
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||
MAX_BASE_JSON_BYTES = 16 * 1024 * 1024
|
||||
@@ -77,6 +83,7 @@ def build_selective_catalog_candidate(
|
||||
check_public: bool = True,
|
||||
write_summary: bool = True,
|
||||
python_artifacts: dict[str, Path | str] | None = None,
|
||||
frozen_source_provenance: dict[str, dict[str, str]] | None = None,
|
||||
) -> SelectiveCatalogCandidate:
|
||||
channel = validate_release_channel(channel)
|
||||
if not repo_versions:
|
||||
@@ -88,15 +95,23 @@ def build_selective_catalog_candidate(
|
||||
|
||||
workspace = resolve_workspace_root(workspace_root)
|
||||
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
||||
enforce_selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
)
|
||||
selected_provenance = selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
)
|
||||
if frozen_source_provenance is None:
|
||||
enforce_selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
)
|
||||
selected_provenance = selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
)
|
||||
else:
|
||||
selected_provenance = enforce_frozen_selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
expected_provenance=frozen_source_provenance,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
)
|
||||
web_root = website_root(workspace)
|
||||
authenticated_base = load_authenticated_catalog_base(
|
||||
base_catalog=base_catalog,
|
||||
@@ -132,7 +147,10 @@ def build_selective_catalog_candidate(
|
||||
]
|
||||
candidate["release"] = release
|
||||
|
||||
repo_contracts = contracts_by_repo(workspace)
|
||||
repo_contracts = contracts_by_repo(
|
||||
workspace,
|
||||
selected_repositories=set(repo_versions),
|
||||
)
|
||||
changes = apply_repo_updates(
|
||||
candidate,
|
||||
repo_versions=repo_versions,
|
||||
@@ -151,6 +169,7 @@ def build_selective_catalog_candidate(
|
||||
payload=candidate,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
require_selected_heads=frozen_source_provenance is None,
|
||||
)
|
||||
|
||||
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
|
||||
@@ -283,11 +302,107 @@ def enforce_selected_source_provenance(
|
||||
)
|
||||
|
||||
|
||||
def enforce_frozen_selected_source_provenance(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
expected_provenance: dict[str, dict[str, str]],
|
||||
workspace: Path,
|
||||
remote: str = "origin",
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Verify detached, clean source checkouts against durable object receipts.
|
||||
|
||||
Normal interactive candidate generation still requires a named, current
|
||||
source branch. The durable executor instead operates on isolated clones of
|
||||
exact annotated origin tags, so it supplies the commit and tag object that
|
||||
must be present at each detached HEAD.
|
||||
"""
|
||||
|
||||
if set(expected_provenance) != set(repo_versions):
|
||||
raise ValueError(
|
||||
"Frozen source provenance must cover exactly the selected repositories."
|
||||
)
|
||||
expected_commits: dict[str, str] = {}
|
||||
expected_tag_objects: dict[str, str] = {}
|
||||
for repo in sorted(repo_versions):
|
||||
identity = expected_provenance.get(repo)
|
||||
commit = identity.get("commit_sha") if isinstance(identity, dict) else None
|
||||
tag_object = (
|
||||
identity.get("tag_object_sha") if isinstance(identity, dict) else None
|
||||
)
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or set(identity) != {"commit_sha", "tag_object_sha"}
|
||||
or not isinstance(commit, str)
|
||||
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit) is None
|
||||
or not isinstance(tag_object, str)
|
||||
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", tag_object) is None
|
||||
):
|
||||
raise ValueError(
|
||||
f"Frozen source provenance is malformed for {repo}."
|
||||
)
|
||||
expected_commits[repo] = commit
|
||||
expected_tag_objects[repo] = tag_object
|
||||
|
||||
failures = list(
|
||||
source_tag_provenance_issues(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
remote=remote,
|
||||
expected_commits=expected_commits,
|
||||
expected_tag_objects=expected_tag_objects,
|
||||
)
|
||||
)
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
for repo, version in sorted(repo_versions.items()):
|
||||
spec = specs.get(repo)
|
||||
if spec is None:
|
||||
continue
|
||||
path = resolve_repo_path(spec, workspace)
|
||||
snapshot = collect_repository_snapshot(
|
||||
spec,
|
||||
workspace_root=workspace,
|
||||
target_tag=f"v{version.removeprefix('v')}",
|
||||
online=False,
|
||||
)
|
||||
if snapshot.dirty_entries:
|
||||
failures.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
f"v{version.removeprefix('v')}",
|
||||
"frozen source worktree is not clean",
|
||||
)
|
||||
)
|
||||
head = git_text(path, "rev-parse", "--verify", "HEAD")
|
||||
if head != expected_commits[repo]:
|
||||
failures.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
f"v{version.removeprefix('v')}",
|
||||
"frozen source HEAD does not match its durable commit receipt",
|
||||
)
|
||||
)
|
||||
if failures:
|
||||
raise ValueError(
|
||||
"Frozen source provenance gate failed: "
|
||||
+ "; ".join(issue.describe() for issue in failures)
|
||||
)
|
||||
observed = selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
)
|
||||
if observed != expected_provenance:
|
||||
raise ValueError(
|
||||
"Frozen source object identities changed during candidate synthesis."
|
||||
)
|
||||
return observed
|
||||
|
||||
|
||||
def enforce_complete_catalog_source_provenance(
|
||||
*,
|
||||
payload: object,
|
||||
workspace: Path,
|
||||
remote: str = "origin",
|
||||
require_selected_heads: bool = True,
|
||||
) -> None:
|
||||
selection = catalog_source_selection(payload)
|
||||
failures = [*selection.issues]
|
||||
@@ -296,7 +411,9 @@ def enforce_complete_catalog_source_provenance(
|
||||
repo_versions=selection.all_versions,
|
||||
workspace=workspace,
|
||||
remote=remote,
|
||||
require_head_repos=selection.selected_versions,
|
||||
require_head_repos=(
|
||||
selection.selected_versions if require_selected_heads else ()
|
||||
),
|
||||
expected_commits=selection.selected_commits,
|
||||
expected_tag_objects=selection.selected_tag_objects,
|
||||
)
|
||||
@@ -408,7 +525,9 @@ def read_bounded_json_source(source: Path | str, *, label: str) -> object:
|
||||
raise ValueError(f"{label.capitalize()} is not valid UTF-8 JSON.") from exc
|
||||
|
||||
|
||||
def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
|
||||
def _read_regular_file_without_symlinks(
|
||||
path: Path, *, label: str, require_private_owner: bool = False
|
||||
) -> bytes:
|
||||
absolute = path.absolute()
|
||||
parts = absolute.parts[1:]
|
||||
if not parts:
|
||||
@@ -439,6 +558,13 @@ def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
|
||||
raise ValueError(
|
||||
f"{label.capitalize()} must be a bounded regular file."
|
||||
)
|
||||
if require_private_owner and (
|
||||
initial.st_uid != os.geteuid() or stat.S_IMODE(initial.st_mode) & 0o077
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label.capitalize()} must be owned by the current operator "
|
||||
"and inaccessible to other users."
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
remaining = initial.st_size
|
||||
while remaining:
|
||||
@@ -474,8 +600,14 @@ def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
|
||||
return max(current + 1, timestamp_sequence)
|
||||
|
||||
|
||||
def contracts_by_repo(workspace: Path) -> dict[str, Any]:
|
||||
specs = load_repository_specs(include_website=False)
|
||||
def contracts_by_repo(
|
||||
workspace: Path, *, selected_repositories: set[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
specs = tuple(
|
||||
spec
|
||||
for spec in load_repository_specs(include_website=False)
|
||||
if selected_repositories is None or spec.name in selected_repositories
|
||||
)
|
||||
snapshots = tuple(
|
||||
collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||
for spec in specs
|
||||
@@ -719,7 +851,11 @@ def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
|
||||
raise ValueError("Catalog signing key ID is not a bounded opaque ID.")
|
||||
path = Path(path_text).expanduser()
|
||||
private_key = serialization.load_pem_private_key(
|
||||
_read_regular_file_without_symlinks(path, label="catalog signing key"),
|
||||
_read_regular_file_without_symlinks(
|
||||
path,
|
||||
label="catalog signing key",
|
||||
require_private_owner=True,
|
||||
),
|
||||
password=None,
|
||||
)
|
||||
if not isinstance(private_key, Ed25519PrivateKey):
|
||||
|
||||
@@ -12,7 +12,11 @@ import tomllib
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .git_state import collect_repository_snapshot, git_text
|
||||
from .git_state import (
|
||||
collect_repository_snapshot,
|
||||
git_text,
|
||||
sanitized_git_environment,
|
||||
)
|
||||
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
|
||||
from .version_alignment import repository_version_issues
|
||||
from .workspace import load_repository_specs, resolve_repo_path
|
||||
@@ -415,16 +419,17 @@ def _git_file(path: Path, tag: str, name: str) -> str | None:
|
||||
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
("git", *args),
|
||||
("/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args),
|
||||
cwd=path,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
env=sanitized_git_environment(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||
return subprocess.CompletedProcess(("/usr/bin/git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||
|
||||
|
||||
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:
|
||||
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
from .git_state import collect_versions
|
||||
from .git_state import collect_versions, sanitized_git_environment
|
||||
from .workspace import load_repository_specs, resolve_repo_path
|
||||
|
||||
|
||||
@@ -684,12 +684,13 @@ def _git_tag_commit(repo_path: Path, tag: str) -> str | None:
|
||||
if not (repo_path / ".git").exists():
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
|
||||
["/usr/bin/git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
env=sanitized_git_environment(),
|
||||
)
|
||||
value = result.stdout.strip()
|
||||
return value if result.returncode == 0 and value else None
|
||||
@@ -699,12 +700,13 @@ def _git_tag_file(repo_path: Path, tag: str, relative_path: str) -> str | None:
|
||||
if not (repo_path / ".git").exists():
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"],
|
||||
["/usr/bin/git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
env=sanitized_git_environment(),
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user