Harden release source execution
This commit is contained in:
710
tests/test_release_execution.py
Normal file
710
tests/test_release_execution.py
Normal file
@@ -0,0 +1,710 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_ROOT))
|
||||
|
||||
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
||||
from govoplan_release.release_execution import ( # noqa: E402
|
||||
ExecutorSpec,
|
||||
ReleaseExecutionAmbiguous,
|
||||
ReleaseExecutionBlocked,
|
||||
build_selected_wheels,
|
||||
execute_repository_step,
|
||||
executor_spec,
|
||||
reconciled_repository_receipt,
|
||||
repository_state_receipt,
|
||||
require_trusted_release_runtime,
|
||||
verify_frozen_repository_tag_receipt,
|
||||
verify_repository_preflight_binding,
|
||||
_clone_catalog_sources,
|
||||
_checkout_frozen_source,
|
||||
_verify_exact_publication_delta,
|
||||
)
|
||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||
enforce_frozen_selected_source_provenance,
|
||||
enforce_selected_source_provenance,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseExecutionTests(unittest.TestCase):
|
||||
def test_git_environment_ignores_inherited_redirection_and_commands(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"GIT_DIR": "/attacker/repository",
|
||||
"GIT_CONFIG_GLOBAL": "/attacker/config",
|
||||
"GIT_SSH_COMMAND": "/attacker/ssh",
|
||||
"PATH": "/attacker/bin",
|
||||
"SSH_AUTH_SOCK": "/operator/agent.sock",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
environment = sanitized_git_environment()
|
||||
|
||||
self.assertNotIn("GIT_DIR", environment)
|
||||
self.assertEqual("/dev/null", environment["GIT_CONFIG_GLOBAL"])
|
||||
self.assertEqual("/usr/bin:/bin", environment["PATH"])
|
||||
self.assertEqual(
|
||||
"/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||
environment["GIT_SSH_COMMAND"],
|
||||
)
|
||||
self.assertEqual("/operator/agent.sock", environment["SSH_AUTH_SOCK"])
|
||||
|
||||
def test_publication_reconciliation_rejects_unrelated_commit_delta(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
website = Path(temp_dir) / "website"
|
||||
_git(Path(temp_dir), "init", "-b", "main", str(website))
|
||||
_git(website, "config", "user.name", "Release Test")
|
||||
_git(website, "config", "user.email", "release@example.test")
|
||||
catalog = website / "public/catalogs/v1/channels/stable.json"
|
||||
keyring = website / "public/catalogs/v1/keyring.json"
|
||||
modules = website / "public/catalogs/v1/modules"
|
||||
catalog.parent.mkdir(parents=True)
|
||||
modules.mkdir(parents=True)
|
||||
catalog.write_text("{}\n", encoding="utf-8")
|
||||
keyring.write_text("{}\n", encoding="utf-8")
|
||||
(website / "README.md").write_text("before\n", encoding="utf-8")
|
||||
_git(website, "add", ".")
|
||||
_git(website, "commit", "-m", "Base")
|
||||
parent = _git_output(website, "rev-parse", "HEAD")
|
||||
|
||||
expected = {
|
||||
"public/catalogs/v1/channels/stable.json": b'{"new": true}\n',
|
||||
"public/catalogs/v1/keyring.json": b"{}\n",
|
||||
}
|
||||
catalog.write_bytes(expected["public/catalogs/v1/channels/stable.json"])
|
||||
(website / "README.md").write_text("unrelated\n", encoding="utf-8")
|
||||
_git(website, "add", ".")
|
||||
_git(website, "commit", "-m", "Catalog plus unrelated change")
|
||||
commit = _git_output(website, "rev-parse", "HEAD")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked,
|
||||
"outside the exact derived delta",
|
||||
):
|
||||
_verify_exact_publication_delta(
|
||||
web_root=website,
|
||||
frozen_parent=parent,
|
||||
commit=commit,
|
||||
expected_blobs=expected,
|
||||
module_root=modules,
|
||||
)
|
||||
|
||||
def test_runtime_trust_rejects_replaceable_workspace_boundary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
meta = root / "govoplan"
|
||||
workspace = root / "workspace"
|
||||
_git(root, "init", "-b", "main", str(meta))
|
||||
(meta / "tools" / "release").mkdir(parents=True)
|
||||
(meta / "tools" / "checks").mkdir(parents=True)
|
||||
(meta / "tools" / "release" / "tool.py").write_text(
|
||||
"# trusted\n", encoding="utf-8"
|
||||
)
|
||||
(meta / "tools" / "checks" / "check.py").write_text(
|
||||
"# trusted\n", encoding="utf-8"
|
||||
)
|
||||
registry = meta / "repositories.json"
|
||||
registry.write_text('{"repositories": []}\n', encoding="utf-8")
|
||||
workspace.mkdir(mode=0o700)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_release.release_execution.META_ROOT",
|
||||
meta,
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.REPOSITORIES_FILE",
|
||||
registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.publication_runtime_trust_issues",
|
||||
return_value=(),
|
||||
),
|
||||
):
|
||||
require_trusted_release_runtime(workspace_root=workspace)
|
||||
workspace.chmod(0o777)
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked, "operator-owned"
|
||||
):
|
||||
require_trusted_release_runtime(workspace_root=workspace)
|
||||
|
||||
def test_durable_binding_rejects_group_writable_git_configuration(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
remote = workspace / "files.git"
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Initial")
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
(repository / ".git" / "config").chmod(0o664)
|
||||
|
||||
with patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=(_repository_spec(remote),),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked, "group/world writable"
|
||||
):
|
||||
repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
def test_durable_binding_rejects_nested_writable_git_authority(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
remote = workspace / "files.git"
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Initial")
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
(repository / ".git" / "refs" / "heads").chmod(0o777)
|
||||
|
||||
with patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=(_repository_spec(remote),),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked,
|
||||
"Git metadata directory",
|
||||
):
|
||||
repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
def test_creation_binding_rejects_head_and_push_remote_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
remote = workspace / "files.git"
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "README.md").write_text("one\n", encoding="utf-8")
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Initial")
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
_git(repository, "push", "-u", "origin", "main")
|
||||
spec = _repository_spec(remote)
|
||||
|
||||
with patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=(spec,),
|
||||
):
|
||||
frozen = repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
step = {
|
||||
"repo": "govoplan-files",
|
||||
"source_binding": frozen,
|
||||
}
|
||||
(repository / "README.md").write_text("two\n", encoding="utf-8")
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Drift")
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked, "state drifted"
|
||||
):
|
||||
verify_repository_preflight_binding(
|
||||
plan_step=step,
|
||||
version="1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
_git(
|
||||
repository,
|
||||
"remote",
|
||||
"set-url",
|
||||
"--add",
|
||||
"--push",
|
||||
"origin",
|
||||
str(workspace / "other.git"),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked, "registered release remote"
|
||||
):
|
||||
repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
def test_reconciliation_proves_annotated_local_and_remote_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
remote = workspace / "files.git"
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Initial")
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
_git(repository, "push", "-u", "origin", "main")
|
||||
spec = _repository_spec(remote)
|
||||
plan_step = {
|
||||
"repo": "govoplan-files",
|
||||
"source_binding": {},
|
||||
}
|
||||
tag_spec = ExecutorSpec("tag", "TAG", "tag_created", True)
|
||||
push_spec = ExecutorSpec("push", "PUBLISH", "tag_published", True)
|
||||
|
||||
with patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=(spec,),
|
||||
):
|
||||
before = repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||
tagged = reconciled_repository_receipt(
|
||||
spec=tag_spec,
|
||||
plan_step=plan_step,
|
||||
version="1.2.3",
|
||||
workspace_root=workspace,
|
||||
expected_receipt=before,
|
||||
)
|
||||
self.assertIsNotNone(tagged["tag_object"])
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked, "Remote annotated tag"
|
||||
):
|
||||
reconciled_repository_receipt(
|
||||
spec=push_spec,
|
||||
plan_step=plan_step,
|
||||
version="1.2.3",
|
||||
workspace_root=workspace,
|
||||
expected_receipt=tagged,
|
||||
)
|
||||
_git(
|
||||
repository,
|
||||
"push",
|
||||
"--atomic",
|
||||
"origin",
|
||||
"HEAD:refs/heads/main",
|
||||
"refs/tags/v1.2.3",
|
||||
)
|
||||
published = reconciled_repository_receipt(
|
||||
spec=push_spec,
|
||||
plan_step=plan_step,
|
||||
version="1.2.3",
|
||||
workspace_root=workspace,
|
||||
expected_receipt=tagged,
|
||||
)
|
||||
self.assertEqual(tagged, published)
|
||||
(repository / "README.md").write_text(
|
||||
"uncommitted drift\n", encoding="utf-8"
|
||||
)
|
||||
self.assertEqual(
|
||||
tagged,
|
||||
verify_frozen_repository_tag_receipt(
|
||||
receipt=tagged,
|
||||
workspace_root=workspace,
|
||||
),
|
||||
)
|
||||
(repository / "README.md").write_text(
|
||||
"remote branch drift\n", encoding="utf-8"
|
||||
)
|
||||
_git(repository, "add", "README.md")
|
||||
_git(repository, "commit", "-m", "Remote branch drift")
|
||||
drift_commit = _git_output(repository, "rev-parse", "HEAD")
|
||||
_git(
|
||||
repository,
|
||||
"push",
|
||||
"origin",
|
||||
f"{drift_commit}:refs/heads/drift-source",
|
||||
)
|
||||
_git(remote, "update-ref", "refs/heads/main", drift_commit)
|
||||
_git(repository, "reset", "--hard", str(tagged["head"]))
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionBlocked,
|
||||
"Remote branch",
|
||||
):
|
||||
reconciled_repository_receipt(
|
||||
spec=push_spec,
|
||||
plan_step=plan_step,
|
||||
version="1.2.3",
|
||||
workspace_root=workspace,
|
||||
expected_receipt=tagged,
|
||||
)
|
||||
|
||||
def test_post_tag_receipt_failure_is_ambiguous(self) -> None:
|
||||
spec = ExecutorSpec("tag", "TAG", "tag_created", True)
|
||||
plan_step = {
|
||||
"id": "govoplan-files:tag",
|
||||
"repo": "govoplan-files",
|
||||
"source_binding": {"kind": "repository_state"},
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"govoplan_release.release_execution.require_trusted_release_runtime"
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.verify_repository_step_precondition"
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.tag_repositories",
|
||||
return_value={
|
||||
"status": "tagged",
|
||||
"repositories": [{"status": "tagged"}],
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.repository_state_receipt",
|
||||
side_effect=ReleaseExecutionBlocked("post-effect probe failed"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ReleaseExecutionAmbiguous, "post-effect repository receipt"
|
||||
):
|
||||
execute_repository_step(
|
||||
spec=spec,
|
||||
plan_step=plan_step,
|
||||
repo_versions={"govoplan-files": "1.2.3"},
|
||||
workspace_root=Path("/workspace"),
|
||||
remote="origin",
|
||||
expected_receipt={"kind": "repository_state"},
|
||||
)
|
||||
|
||||
def test_commit_step_has_no_durable_executor(self) -> None:
|
||||
self.assertIsNone(
|
||||
executor_spec(
|
||||
{
|
||||
"id": "govoplan-files:commit",
|
||||
"status": "planned",
|
||||
"mutating": True,
|
||||
"repo": "govoplan-files",
|
||||
"source_binding": {
|
||||
"kind": "repository_state",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
repository.mkdir()
|
||||
_git(repository, "init", "-b", "main")
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "version.txt").write_text("frozen\n", encoding="utf-8")
|
||||
_git(repository, "add", "version.txt")
|
||||
_git(repository, "commit", "-m", "Frozen")
|
||||
frozen_commit = _git_output(repository, "rev-parse", "HEAD")
|
||||
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||
tag_object = _git_output(repository, "rev-parse", "refs/tags/v1.2.3")
|
||||
(repository / "version.txt").write_text("live drift\n", encoding="utf-8")
|
||||
|
||||
source_root = workspace / "isolated"
|
||||
source_root.mkdir()
|
||||
checkout = _checkout_frozen_source(
|
||||
repo="govoplan-files",
|
||||
source_remote=repository,
|
||||
commit=frozen_commit,
|
||||
tag="v1.2.3",
|
||||
tag_object=tag_object,
|
||||
source_root=source_root,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"frozen\n", (checkout / "version.txt").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def test_frozen_provenance_accepts_only_exact_clean_detached_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repository = workspace / "govoplan-files"
|
||||
remote = workspace / "files.git"
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(repository, "add", "pyproject.toml")
|
||||
_git(repository, "commit", "-m", "Release")
|
||||
commit = _git_output(repository, "rev-parse", "HEAD")
|
||||
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||
tag_object = _git_output(
|
||||
repository, "rev-parse", "refs/tags/v1.2.3"
|
||||
)
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
_git(repository, "push", "origin", "main", "refs/tags/v1.2.3")
|
||||
_git(repository, "checkout", "--detach", commit)
|
||||
spec = _repository_spec(remote)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_release.source_provenance.load_repository_specs",
|
||||
return_value=(spec,),
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.selective_catalog.load_repository_specs",
|
||||
return_value=(spec,),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "named branch"):
|
||||
enforce_selected_source_provenance(
|
||||
repo_versions={"govoplan-files": "1.2.3"},
|
||||
workspace=workspace,
|
||||
)
|
||||
observed = enforce_frozen_selected_source_provenance(
|
||||
repo_versions={"govoplan-files": "1.2.3"},
|
||||
expected_provenance={
|
||||
"govoplan-files": {
|
||||
"commit_sha": commit,
|
||||
"tag_object_sha": tag_object,
|
||||
}
|
||||
},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual(commit, observed["govoplan-files"]["commit_sha"])
|
||||
self.assertEqual(
|
||||
tag_object, observed["govoplan-files"]["tag_object_sha"]
|
||||
)
|
||||
|
||||
def test_wheel_staging_skips_selected_tag_only_repository(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
python_repo = workspace / "govoplan-files"
|
||||
tag_only_repo = workspace / "govoplan-meta-notes"
|
||||
python_repo.mkdir()
|
||||
tag_only_repo.mkdir()
|
||||
(python_repo / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
specs = (
|
||||
RepositorySpec(
|
||||
name="govoplan-files",
|
||||
category="module",
|
||||
subtype="infrastructure",
|
||||
remote="git@example.test/files.git",
|
||||
path="govoplan-files",
|
||||
),
|
||||
RepositorySpec(
|
||||
name="govoplan-meta-notes",
|
||||
category="system",
|
||||
subtype="metadata",
|
||||
remote="git@example.test/notes.git",
|
||||
path="govoplan-meta-notes",
|
||||
),
|
||||
)
|
||||
candidate = workspace / "candidate"
|
||||
|
||||
def build(command: tuple[str, ...]) -> int:
|
||||
wheel_dir = Path(command[command.index("--bind") + 1])
|
||||
(
|
||||
wheel_dir / "govoplan_files-1.2.3-py3-none-any.whl"
|
||||
).write_bytes(b"wheel")
|
||||
return 0
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=specs,
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution.isolated_builder_launcher",
|
||||
return_value=("/usr/bin/bwrap",),
|
||||
),
|
||||
patch(
|
||||
"govoplan_release.release_execution._run_isolated_wheel_builder",
|
||||
side_effect=build,
|
||||
) as runner,
|
||||
patch(
|
||||
"govoplan_release.release_execution.inspect_python_wheel",
|
||||
return_value=SimpleNamespace(
|
||||
package_name="govoplan-files",
|
||||
package_version="1.2.3",
|
||||
),
|
||||
),
|
||||
):
|
||||
artifacts = build_selected_wheels(
|
||||
repo_versions={
|
||||
"govoplan-files": "1.2.3",
|
||||
"govoplan-meta-notes": "1.0.0",
|
||||
},
|
||||
workspace_root=workspace,
|
||||
candidate_output=candidate,
|
||||
source_receipts={
|
||||
"govoplan-files": {
|
||||
"head": "a" * 40,
|
||||
"target_tag": "v1.2.3",
|
||||
"tag_object": "c" * 40,
|
||||
},
|
||||
"govoplan-meta-notes": {
|
||||
"head": "b" * 40,
|
||||
"target_tag": "v1.0.0",
|
||||
"tag_object": "d" * 40,
|
||||
},
|
||||
},
|
||||
frozen_sources={
|
||||
"govoplan-files": python_repo,
|
||||
"govoplan-meta-notes": tag_only_repo,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual({"govoplan-files"}, set(artifacts))
|
||||
self.assertEqual(1, runner.call_count)
|
||||
command = runner.call_args.args[0]
|
||||
self.assertIn(str(python_repo), command)
|
||||
self.assertIn("--unshare-all", command)
|
||||
self.assertIn("PIP_NO_INDEX", command)
|
||||
self.assertNotIn(str(Path.home()), command)
|
||||
|
||||
def test_catalog_synthesis_clones_selected_and_unchanged_base_sources(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
files_repo, files_remote = _tagged_repository(
|
||||
workspace,
|
||||
repo="govoplan-files",
|
||||
bare="files.git",
|
||||
version="1.2.3",
|
||||
)
|
||||
core_repo, core_remote = _tagged_repository(
|
||||
workspace,
|
||||
repo="govoplan-core",
|
||||
bare="core.git",
|
||||
version="1.0.0",
|
||||
)
|
||||
specs = (
|
||||
_repository_spec(files_remote),
|
||||
RepositorySpec(
|
||||
name="govoplan-core",
|
||||
category="core",
|
||||
subtype="core",
|
||||
remote=str(core_remote),
|
||||
path="govoplan-core",
|
||||
),
|
||||
)
|
||||
destination = workspace / "synthesis"
|
||||
destination.mkdir()
|
||||
|
||||
with patch(
|
||||
"govoplan_release.release_execution.load_repository_specs",
|
||||
return_value=specs,
|
||||
):
|
||||
receipt = repository_state_receipt(
|
||||
repo="govoplan-files",
|
||||
target_tag="v1.2.3",
|
||||
workspace_root=workspace,
|
||||
)
|
||||
cloned = _clone_catalog_sources(
|
||||
source_versions={
|
||||
"govoplan-core": "1.0.0",
|
||||
"govoplan-files": "1.2.3",
|
||||
},
|
||||
repo_versions={"govoplan-files": "1.2.3"},
|
||||
source_receipts={"govoplan-files": receipt},
|
||||
workspace_root=workspace,
|
||||
destination=destination,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_git_output(files_repo, "rev-parse", "refs/tags/v1.2.3^{commit}"),
|
||||
_git_output(cloned["govoplan-files"], "rev-parse", "HEAD"),
|
||||
)
|
||||
self.assertEqual(
|
||||
_git_output(core_repo, "rev-parse", "refs/tags/v1.0.0^{commit}"),
|
||||
_git_output(cloned["govoplan-core"], "rev-parse", "HEAD"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"HEAD",
|
||||
_git_output(cloned["govoplan-core"], "rev-parse", "--abbrev-ref", "HEAD"),
|
||||
)
|
||||
|
||||
|
||||
def _repository_spec(remote: Path) -> RepositorySpec:
|
||||
return RepositorySpec(
|
||||
name="govoplan-files",
|
||||
category="module",
|
||||
subtype="infrastructure",
|
||||
remote=str(remote),
|
||||
path="govoplan-files",
|
||||
)
|
||||
|
||||
|
||||
def _tagged_repository(
|
||||
workspace: Path, *, repo: str, bare: str, version: str
|
||||
) -> tuple[Path, Path]:
|
||||
repository = workspace / repo
|
||||
remote = workspace / bare
|
||||
_git(workspace, "init", "--bare", str(remote))
|
||||
_git(workspace, "init", "-b", "main", str(repository))
|
||||
_git(repository, "config", "user.name", "Release Test")
|
||||
_git(repository, "config", "user.email", "release@example.test")
|
||||
(repository / "pyproject.toml").write_text(
|
||||
f'[project]\nname = "{repo}"\nversion = "{version}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(repository, "add", "pyproject.toml")
|
||||
_git(repository, "commit", "-m", "Release")
|
||||
_git(repository, "tag", "-a", f"v{version}", "-m", "Release")
|
||||
_git(repository, "remote", "add", "origin", str(remote))
|
||||
_git(repository, "push", "-u", "origin", "main", f"refs/tags/v{version}")
|
||||
return repository, remote
|
||||
|
||||
|
||||
def _git(cwd: Path, *arguments: str) -> None:
|
||||
subprocess.run( # noqa: S603
|
||||
("git", *arguments),
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _git_output(cwd: Path, *arguments: str) -> str:
|
||||
return subprocess.run( # noqa: S603
|
||||
("git", *arguments),
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,6 +18,29 @@ from govoplan_release.module_directory import ( # noqa: E402
|
||||
|
||||
|
||||
class ReleaseModuleDirectoryTests(unittest.TestCase):
|
||||
def test_signed_catalog_timestamp_makes_derived_files_reproducible(self) -> None:
|
||||
catalog = {
|
||||
"generated_at": "2026-07-22T12:00:00Z",
|
||||
"sequence": 7,
|
||||
"modules": [],
|
||||
}
|
||||
first = module_directory_payloads(
|
||||
catalog_payload=catalog,
|
||||
keyring_payload={},
|
||||
channel="stable",
|
||||
)
|
||||
second = module_directory_payloads(
|
||||
catalog_payload=catalog,
|
||||
keyring_payload={},
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(
|
||||
"2026-07-22T12:00:00Z",
|
||||
first[-1][1]["generated_at"],
|
||||
)
|
||||
|
||||
def test_dot_segments_and_noncanonical_ids_never_become_paths(self) -> None:
|
||||
for value in (".", "..", "../escape", "/absolute", "module/child"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
|
||||
@@ -23,6 +23,7 @@ from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||
build_selective_catalog_candidate,
|
||||
enforce_selected_source_provenance,
|
||||
parse_signing_key,
|
||||
public_key_base64,
|
||||
signature,
|
||||
)
|
||||
@@ -159,6 +160,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o600)
|
||||
output = self.root / "candidate"
|
||||
|
||||
with patch(
|
||||
@@ -181,6 +183,25 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
|
||||
self.assertEqual("test-key", payload["signatures"][0]["key_id"])
|
||||
|
||||
def test_catalog_signing_key_must_be_operator_private(self) -> None:
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
key_path = self.root / "release.pem"
|
||||
key_path.write_bytes(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o644)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "inaccessible to other users"):
|
||||
parse_signing_key(f"test-key={key_path}")
|
||||
|
||||
key_path.chmod(0o600)
|
||||
key_id, _parsed = parse_signing_key(f"test-key={key_path}")
|
||||
self.assertEqual("test-key", key_id)
|
||||
|
||||
def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
|
||||
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||
@@ -235,22 +256,21 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
self.assertIn("Array.isArray(result.notes)", ui)
|
||||
self.assertIn("Validation and provenance details", ui)
|
||||
|
||||
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None:
|
||||
with patch(
|
||||
"server.app.build_selective_catalog_candidate",
|
||||
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"),
|
||||
):
|
||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||
response = client.post(
|
||||
"/api/catalog-candidates",
|
||||
json={
|
||||
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||
"signing_keys": ["test=/unused/key.pem"],
|
||||
},
|
||||
)
|
||||
def test_console_disables_unclaimed_legacy_candidate_signing(self) -> None:
|
||||
with TestClient(
|
||||
create_app(workspace_root=self.workspace, token="token")
|
||||
) as client:
|
||||
response = client.post(
|
||||
"/api/catalog-candidates",
|
||||
headers={"X-Release-Console-Token": "token"},
|
||||
json={
|
||||
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||
"signing_keys": ["test=/unused/key.pem"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(409, response.status_code)
|
||||
self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"])
|
||||
self.assertIn("durable release run", response.json()["detail"])
|
||||
|
||||
def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -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