diff --git a/Makefile b/Makefile index 4d91b76..a388665 100755 --- a/Makefile +++ b/Makefile @@ -74,14 +74,53 @@ define verify_sha256 @$(PYTHON) scripts/verify_artifact.py "$(1)" endef -define validate_if_present - @if [ -f "$(1)" ] || [ -f "$(1).sha256" ]; then \ +PYPI_ARTIFACT_VALIDATION_TARGET ?= validate-pypi-contract + +define validate_artifact_if_requested + @if [ -f "$(1)" ] && [ -f "$(1).sha256" ]; then \ $(MAKE) $(2); \ + elif [ -f "$(1)" ]; then \ + echo "Missing checksum sidecar for $(3): $(1).sha256"; \ + exit 3; \ + elif [ -f "$(1).sha256" ]; then \ + echo "Orphan checksum sidecar for $(3): $(1).sha256"; \ + echo "Missing artifact for $(3): $(1)"; \ + exit 2; \ else \ echo "SKIP: $(3) artifact not built: $(1)"; \ fi endef +define validate_pypi_artifacts_if_requested + @present=0; \ + for required in "$(PYPI_WHEEL_FILE)" "$(PYPI_WHEEL_FILE).sha256" "$(PYPI_SDIST_FILE)" "$(PYPI_SDIST_FILE).sha256"; do \ + if [ -f "$$required" ]; then \ + present=$$((present + 1)); \ + fi; \ + done; \ + if [ "$$present" -eq 0 ]; then \ + echo "SKIP: PyPI artifacts not built under $(PYPI_PKG_DIR)"; \ + elif [ "$$present" -eq 4 ]; then \ + $(MAKE) $(PYPI_ARTIFACT_VALIDATION_TARGET); \ + else \ + echo "ERROR: incomplete PyPI artifact set under $(PYPI_PKG_DIR)"; \ + for required in "$(PYPI_WHEEL_FILE)" "$(PYPI_WHEEL_FILE).sha256" "$(PYPI_SDIST_FILE)" "$(PYPI_SDIST_FILE).sha256"; do \ + if [ ! -f "$$required" ]; then \ + echo "Missing $$required"; \ + fi; \ + done; \ + exit 3; \ + fi +endef + +define validate_release_assets_if_present + @if [ -d "$(RELEASE_DIR)" ]; then \ + $(MAKE) validate-release-assets; \ + else \ + echo "SKIP: release asset set not assembled under $(RELEASE_DIR)"; \ + fi +endef + define assert_current_release_file @case "$(1)" in \ releases/$(PACKAGE_VERSION)/*) ;; \ @@ -1172,6 +1211,11 @@ validate-official-evidence-drift: @$(UV) run python scripts/f4_linter_linux_provisioning.py --check-official-evidence-drift @echo "--> OK: Linux official distro evidence drift gate" +.PHONY: validate-pypi-source-contract +validate-pypi-source-contract: + @$(PYTHON) scripts/publish_pypi.py --dry-run + @echo "--> OK: PyPI source contract" + .PHONY: validate-pypi-contract validate-pypi-contract: @test -f "$(PYPI_WHEEL_FILE)" || (echo "Missing $(PYPI_WHEEL_FILE)"; exit 2) @@ -1215,21 +1259,21 @@ validate-windows-contract: package-windows-assert @echo "--> OK: Windows contract" .PHONY: validate-gate2 -validate-gate2: validate-version-consistency validate-runtime-imports validate-official-evidence-drift - @if [ -f "$(PYPI_WHEEL_FILE)" ] || [ -f "$(PYPI_WHEEL_FILE).sha256" ] || [ -f "$(PYPI_SDIST_FILE)" ] || [ -f "$(PYPI_SDIST_FILE).sha256" ]; then \ - $(MAKE) validate-pypi-contract; \ - else \ - echo "SKIP: PyPI artifacts not built under $(PYPI_PKG_DIR)"; \ - fi - $(call validate_if_present,$(DEB_PKG_FILE),validate-deb-contract,DEB) - $(call validate_if_present,$(RPM_PKG_FILE),validate-rpm-contract,RPM) - $(call validate_if_present,$(APPIMAGE_FILE),validate-appimage-contract,AppImage) - $(call validate_if_present,$(TAR_LINUX_FILE),validate-tar-linux-contract,Linux tar) - $(call validate_if_present,$(FREEBSD_PKG_FILE),validate-freebsd-contract,FreeBSD) - $(call validate_if_present,$(MACOS_PKG_FILE),validate-macos-contract,macOS) - $(call validate_if_present,$(WIN_PKG_FILE),validate-windows-contract,Windows) - $(MAKE) validate-release-assets - @echo "--> OK: Gate 2 validation completed for built artifacts" +validate-gate2: validate-version-consistency validate-runtime-imports validate-official-evidence-drift validate-pypi-source-contract + @echo "--> OK: Gate 2 source and structural contract validation completed" + +.PHONY: validate-built-artifacts +validate-built-artifacts: + $(call validate_pypi_artifacts_if_requested) + $(call validate_artifact_if_requested,$(DEB_PKG_FILE),validate-deb-contract,DEB) + $(call validate_artifact_if_requested,$(RPM_PKG_FILE),validate-rpm-contract,RPM) + $(call validate_artifact_if_requested,$(APPIMAGE_FILE),validate-appimage-contract,AppImage) + $(call validate_artifact_if_requested,$(TAR_LINUX_FILE),validate-tar-linux-contract,Linux tar) + $(call validate_artifact_if_requested,$(FREEBSD_PKG_FILE),validate-freebsd-contract,FreeBSD) + $(call validate_artifact_if_requested,$(MACOS_PKG_FILE),validate-macos-contract,macOS) + $(call validate_artifact_if_requested,$(WIN_PKG_FILE),validate-windows-contract,Windows) + $(call validate_release_assets_if_present) + @echo "--> OK: built artifact validation completed" # ============================================================================= diff --git a/docs/release/release-process.md b/docs/release/release-process.md index ac2fcad..c3c8800 100644 --- a/docs/release/release-process.md +++ b/docs/release/release-process.md @@ -95,9 +95,14 @@ maintainer environment: python3 -m pip install -e ".[release]" ``` -The `validate-gate2` target requires `twine` for strict PyPI wheel/sdist -metadata validation. `twine` is declared only in release/development tooling -dependencies, not in ECLI runtime dependencies. +The `validate-gate2` target runs source and structural contract validation +without inspecting pre-existing local release artifacts. Use +`make validate-built-artifacts` for explicit final artifact verification; that +target validates complete artifact/sidecar pairs and fails closed on partial +artifact sets. When a complete wheel/sdist set and adjacent sidecars are present, +it delegates to `validate-pypi-contract`, which requires `twine` for strict PyPI +wheel/sdist metadata validation. `twine` is declared only in release/development +tooling dependencies, not in ECLI runtime dependencies. ## PyPI Namespace Pre-Reservation diff --git a/tests/packaging/test_release_asset_count_gate.py b/tests/packaging/test_release_asset_count_gate.py index 555a2cf..cd2132a 100644 --- a/tests/packaging/test_release_asset_count_gate.py +++ b/tests/packaging/test_release_asset_count_gate.py @@ -22,6 +22,7 @@ from __future__ import annotations import re +import subprocess from pathlib import Path from types import ModuleType @@ -47,6 +48,27 @@ def _write_assets(directory: Path, names: tuple[str, ...]) -> None: (directory / name).write_text(f"{name}\n", encoding="utf-8") +def _run_probe_make( + repo_root: Path, + tmp_path: Path, + body: str, + target: str, + *overrides: str, +) -> subprocess.CompletedProcess[str]: + probe = tmp_path / "Probe.mk" + probe.write_text( + f"include {repo_root / 'Makefile'}\n\n{body}", + encoding="utf-8", + ) + return subprocess.run( + ["make", "-f", str(probe), target, f"MAKE=make -f {probe}", *overrides], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + + # --------------------------------------------------------------------------- # # Canonical name set shape # --------------------------------------------------------------------------- # @@ -364,6 +386,268 @@ def test_makefile_and_release_workflow_call_exact_asset_verifier( assert "fail_on_unmatched_files: true" in release_workflow +def test_validate_gate2_separates_source_and_built_artifact_checks( + read_repo_text: RepoReader, +) -> None: + makefile = read_repo_text("Makefile") + source_target = makefile[ + makefile.index(".PHONY: validate-pypi-source-contract") : makefile.index( + ".PHONY: validate-pypi-contract" + ) + ] + built_target = makefile[ + makefile.index(".PHONY: validate-pypi-contract") : makefile.index( + ".PHONY: validate-tar-linux-contract" + ) + ] + gate2 = makefile[ + makefile.index(".PHONY: validate-gate2") : makefile.index( + ".PHONY: validate-built-artifacts", + makefile.index(".PHONY: validate-gate2"), + ) + ] + built_artifacts = makefile[ + makefile.index(".PHONY: validate-built-artifacts") : makefile.index( + "# =============================================================================", + makefile.index(".PHONY: validate-built-artifacts"), + ) + ] + + assert "scripts/publish_pypi.py --dry-run" in source_target + assert "$(PYTHON) -m twine check --strict" not in source_target + assert "$(PYTHON) -m twine check --strict" in built_target + assert "$(call verify_sha256,$(PYPI_WHEEL_FILE))" in built_target + + assert "validate-pypi-source-contract" in gate2 + assert "$(call validate_pypi_artifacts_if_requested)" not in gate2 + assert "$(call validate_artifact_if_requested" not in gate2 + assert "$(call validate_release_assets_if_present)" not in gate2 + + assert "$(call validate_pypi_artifacts_if_requested)" in built_artifacts + assert "$(call validate_artifact_if_requested" in built_artifacts + assert "$(call validate_release_assets_if_present)" in built_artifacts + + +def test_validate_gate2_requires_complete_artifact_sidecar_pairs( + read_repo_text: RepoReader, +) -> None: + makefile = read_repo_text("Makefile") + generic_helper = makefile[ + makefile.index("define validate_artifact_if_requested") : makefile.index( + "define validate_pypi_artifacts_if_requested" + ) + ] + pypi_helper = makefile[ + makefile.index("define validate_pypi_artifacts_if_requested") : makefile.index( + "define validate_release_assets_if_present" + ) + ] + + assert '[ -f "$(1)" ] && [ -f "$(1).sha256" ]' in generic_helper + assert "Missing checksum sidecar for $(3): $(1).sha256" in generic_helper + assert "Orphan checksum sidecar for $(3): $(1).sha256" in generic_helper + assert "ERROR: incomplete PyPI artifact set" in pypi_helper + assert 'echo "Missing $$required"' in pypi_helper + + +def test_single_artifact_helper_skips_when_absent( + repo_root: Path, + tmp_path: Path, +) -> None: + artifact = tmp_path / "artifact.bin" + result = _run_probe_make( + repo_root, + tmp_path, + ( + ".PHONY: probe fake-validator\n" + "probe:\n" + f"\t$(call validate_artifact_if_requested,{artifact},fake-validator,Probe)\n" + "fake-validator:\n" + "\t@echo SINGLE_VALIDATOR_INVOKED\n" + ), + "probe", + ) + + assert result.returncode == 0 + assert "SKIP: Probe artifact not built" in result.stdout + assert "SINGLE_VALIDATOR_INVOKED" not in result.stdout + + +def test_single_artifact_helper_invokes_validator_when_complete( + repo_root: Path, + tmp_path: Path, +) -> None: + artifact = tmp_path / "artifact.bin" + artifact.write_text("artifact\n", encoding="utf-8") + artifact.with_name(f"{artifact.name}.sha256").write_text( + "checksum\n", encoding="utf-8" + ) + + result = _run_probe_make( + repo_root, + tmp_path, + ( + ".PHONY: probe fake-validator\n" + "probe:\n" + f"\t$(call validate_artifact_if_requested,{artifact},fake-validator,Probe)\n" + "fake-validator:\n" + "\t@echo SINGLE_VALIDATOR_INVOKED\n" + ), + "probe", + ) + + assert result.returncode == 0 + assert "SINGLE_VALIDATOR_INVOKED" in result.stdout + + +def test_single_artifact_helper_fails_when_sidecar_missing( + repo_root: Path, + tmp_path: Path, +) -> None: + artifact = tmp_path / "artifact.bin" + artifact.write_text("artifact\n", encoding="utf-8") + + result = _run_probe_make( + repo_root, + tmp_path, + ( + ".PHONY: probe fake-validator\n" + "probe:\n" + f"\t$(call validate_artifact_if_requested,{artifact},fake-validator,Probe)\n" + "fake-validator:\n" + "\t@echo SINGLE_VALIDATOR_INVOKED\n" + ), + "probe", + ) + + assert result.returncode != 0 + assert f"Missing checksum sidecar for Probe: {artifact}.sha256" in result.stdout + assert "SINGLE_VALIDATOR_INVOKED" not in result.stdout + + +def test_single_artifact_helper_fails_when_sidecar_is_orphaned( + repo_root: Path, + tmp_path: Path, +) -> None: + artifact = tmp_path / "artifact.bin" + artifact.with_name(f"{artifact.name}.sha256").write_text( + "checksum\n", encoding="utf-8" + ) + + result = _run_probe_make( + repo_root, + tmp_path, + ( + ".PHONY: probe fake-validator\n" + "probe:\n" + f"\t$(call validate_artifact_if_requested,{artifact},fake-validator,Probe)\n" + "fake-validator:\n" + "\t@echo SINGLE_VALIDATOR_INVOKED\n" + ), + "probe", + ) + + assert result.returncode != 0 + assert f"Orphan checksum sidecar for Probe: {artifact}.sha256" in result.stdout + assert f"Missing artifact for Probe: {artifact}" in result.stdout + assert "SINGLE_VALIDATOR_INVOKED" not in result.stdout + + +def _pypi_probe_paths(tmp_path: Path) -> dict[str, Path]: + return { + "wheel": tmp_path / "ecli_editor-1.2.3-py3-none-any.whl", + "wheel_sha": tmp_path / "ecli_editor-1.2.3-py3-none-any.whl.sha256", + "sdist": tmp_path / "ecli_editor-1.2.3.tar.gz", + "sdist_sha": tmp_path / "ecli_editor-1.2.3.tar.gz.sha256", + } + + +def _run_pypi_probe( + repo_root: Path, + tmp_path: Path, +) -> subprocess.CompletedProcess[str]: + paths = _pypi_probe_paths(tmp_path) + return _run_probe_make( + repo_root, + tmp_path, + ( + ".PHONY: probe fake-pypi-validator\n" + "probe:\n" + "\t$(call validate_pypi_artifacts_if_requested)\n" + "fake-pypi-validator:\n" + "\t@echo PYPI_VALIDATOR_INVOKED\n" + ), + "probe", + f"PYPI_PKG_DIR={tmp_path}", + f"PYPI_WHEEL_FILE={paths['wheel']}", + f"PYPI_SDIST_FILE={paths['sdist']}", + "PYPI_ARTIFACT_VALIDATION_TARGET=fake-pypi-validator", + ) + + +def test_pypi_helper_skips_when_no_distribution_files( + repo_root: Path, + tmp_path: Path, +) -> None: + result = _run_pypi_probe(repo_root, tmp_path) + + assert result.returncode == 0 + assert "SKIP: PyPI artifacts not built under" in result.stdout + assert "PYPI_VALIDATOR_INVOKED" not in result.stdout + + +def test_pypi_helper_invokes_validator_for_complete_distribution_set( + repo_root: Path, + tmp_path: Path, +) -> None: + for path in _pypi_probe_paths(tmp_path).values(): + path.write_text("x\n", encoding="utf-8") + + result = _run_pypi_probe(repo_root, tmp_path) + + assert result.returncode == 0 + assert "PYPI_VALIDATOR_INVOKED" in result.stdout + + +_PYPI_PARTIAL_CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("wheel-only", ("wheel",)), + ("wheel-and-wheel-sidecar", ("wheel", "wheel_sha")), + ("orphan-wheel-sidecar", ("wheel_sha",)), + ("sdist-only", ("sdist",)), + ("orphan-sdist-sidecar", ("sdist_sha",)), + ("wheel-and-sdist", ("wheel", "sdist")), + ("wheel-and-orphan-sdist-sidecar", ("wheel", "sdist_sha")), + ("wheel-sidecar-and-sdist", ("wheel_sha", "sdist")), + ("wheel-sidecar-and-sdist-sidecar", ("wheel_sha", "sdist_sha")), + ("sdist-pair-only", ("sdist", "sdist_sha")), + ("missing-sdist-sidecar", ("wheel", "wheel_sha", "sdist")), + ("missing-sdist", ("wheel", "wheel_sha", "sdist_sha")), + ("missing-wheel-sidecar", ("wheel", "sdist", "sdist_sha")), + ("missing-wheel", ("wheel_sha", "sdist", "sdist_sha")), +) + + +@pytest.mark.parametrize(("case_name", "present_keys"), _PYPI_PARTIAL_CASES) +def test_pypi_helper_fails_closed_for_partial_distribution_sets( + repo_root: Path, + tmp_path: Path, + case_name: str, + present_keys: tuple[str, ...], +) -> None: + paths = _pypi_probe_paths(tmp_path) + for key in present_keys: + paths[key].write_text(f"{case_name}\n", encoding="utf-8") + + result = _run_pypi_probe(repo_root, tmp_path) + + assert result.returncode != 0 + assert "ERROR: incomplete PyPI artifact set under" in result.stdout + assert "PYPI_VALIDATOR_INVOKED" not in result.stdout + for key, path in paths.items(): + if key not in present_keys: + assert f"Missing {path}" in result.stdout + + def test_release_workflow_does_not_upload_prefixed_asset_names( read_repo_text: RepoReader, ) -> None: