diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 532a317..3b4a0fa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "celltypepilot", - "version": "0.4.0", + "version": "0.4.1", "description": "Single-cell annotation review plugin — governed context, independent state evidence, conservative abstention, and critic checks.", "author": { "name": "HERRY423" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 397ba4b..8fba9ab 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "celltypepilot", - "version": "0.4.0", + "version": "0.4.1", "description": "Single-cell annotation review plugin — governed context, independent state evidence, conservative abstention, and critic checks.", "author": { "name": "HERRY423", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d6bcdd..255ffc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,17 +57,28 @@ jobs: run: | python -m venv --system-site-packages .release-smoke .release-smoke/bin/python -m pip install --no-deps --force-reinstall dist/*.whl - .release-smoke/bin/python -c \ - "import celltypepilot; assert celltypepilot.__version__ == '${GITHUB_REF_NAME#v}'" - .release-smoke/bin/celltypepilot --version - .release-smoke/bin/celltypepilot doctor --json + .release-smoke/bin/python -I -c \ + "import pathlib, sys, celltypepilot; root=pathlib.Path(sys.prefix).resolve(); module=pathlib.Path(celltypepilot.__file__).resolve(); assert module.is_relative_to(root), (module, root); assert celltypepilot.__version__ == '${GITHUB_REF_NAME#v}'" + + smoke_cwd="$(mktemp -d)" + smoke_cli="$GITHUB_WORKSPACE/.release-smoke/bin/celltypepilot" + ( + cd "$smoke_cwd" + "$smoke_cli" --version + "$smoke_cli" doctor --json + "$smoke_cli" governance-freeze-verify + ) mkdir plugin-smoke python -m zipfile -e release-assets/celltypepilot-plugin-*.zip plugin-smoke .release-smoke/bin/python -m pip install \ --no-build-isolation --no-deps --force-reinstall \ plugin-smoke/celltypepilot-plugin-* - .release-smoke/bin/celltypepilot --version + ( + cd "$smoke_cwd" + "$smoke_cli" --version + "$smoke_cli" governance-freeze-verify + ) - name: Upload Python distributions uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bc1b9..8aa5400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to CellTypePilot are documented here. The project follows [Semantic Versioning](https://semver.org/). Release claims remain bounded by the validation scope recorded in the repository and generated manifests. +## [0.4.1] - 2026-08-11 + +### Fixed + +- Governance-freeze schema v2 hashes governed UTF-8 text after LF normalization, so the same + semantic release verifies across Windows source trees and Linux-built wheels. +- Release smoke tests now assert that imports resolve inside the fresh release virtual environment + and run governance verification from outside the repository checkout. + +### Validation boundary + +- This patch changes cross-platform integrity verification only. It does not change candidate + selection, Atlas content, calibration evidence, benchmark results, or biological claims. + ## [0.4.0] - 2026-08-11 ### Added @@ -79,6 +93,7 @@ validation scope recorded in the repository and generated manifests. - It does not establish biological superiority over CellTypist, SingleR, Azimuth, popV, or expert review. - A qualified human remains responsible for final annotations and biological claims. +[0.4.1]: https://github.com/HERRY423/CellTypePilot/releases/tag/v0.4.1 [0.4.0]: https://github.com/HERRY423/CellTypePilot/releases/tag/v0.4.0 [0.3.1]: https://github.com/HERRY423/CellTypePilot/releases/tag/v0.3.1 [0.3.0]: https://github.com/HERRY423/CellTypePilot/releases/tag/v0.3.0 diff --git a/README.md b/README.md index 750b615..fc8d8fb 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ output/ ├── transitional_states.csv # Clusters flagged as differentiation intermediates ├── disagreements.csv # Marker vs reference disagreement analysis ├── report_draft.html # Self-contained HTML report with all figures embedded -├── methodology_draft.txt # "We annotated N clusters using CellTypePilot v0.4.0..." +├── methodology_draft.txt # "We annotated N clusters using CellTypePilot v0.4.1..." ├── manifest.json # Provenance: versions, params, data hash, output hashes └── figures/ ├── umap_cluster.png # UMAP by cluster (colorblind-friendly Wong palette) @@ -385,9 +385,9 @@ reference, evidence, and provenance gates apply as the CLI. ``` CellTypePilot/ ├── .claude-plugin/ -│ └── plugin.json ← Claude Code plugin manifest (v0.4.0) +│ └── plugin.json ← Claude Code plugin manifest (v0.4.1) ├── .codex-plugin/ -│ └── plugin.json ← Codex plugin manifest (v0.4.0, with interface block) +│ └── plugin.json ← Codex plugin manifest (v0.4.1, with interface block) ├── skills/ │ └── celltypepilot/ │ ├── SKILL.md ← Shared skill instructions (4-stage workflow) diff --git a/pyproject.toml b/pyproject.toml index 0b88642..d6b64ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "celltypepilot" -version = "0.4.0" +version = "0.4.1" description = "Deterministic single-cell annotation backend for Agent plugins" readme = "README.md" license = "MIT" diff --git a/scripts/build_governance_freeze.py b/scripts/build_governance_freeze.py index 9046c14..936d4df 100644 --- a/scripts/build_governance_freeze.py +++ b/scripts/build_governance_freeze.py @@ -17,7 +17,7 @@ def main() -> None: parser.add_argument("--release-id", required=True) parser.add_argument( "--output", - default="src/celltypepilot/data/governance_freeze_v1.json", + default="src/celltypepilot/data/governance_freeze_v2.json", ) args = parser.parse_args() output = Path(args.output) diff --git a/skills/celltypepilot/reference/outputs.md b/skills/celltypepilot/reference/outputs.md index bd1900b..8cdeaa2 100644 --- a/skills/celltypepilot/reference/outputs.md +++ b/skills/celltypepilot/reference/outputs.md @@ -83,7 +83,7 @@ does not contribute marker evidence. ```json { - "celltypepilot_version": "0.4.0", + "celltypepilot_version": "0.4.1", "mkg_version": "mkg-2026.08.1", "timestamp": "2026-08-06T12:00:00+00:00", "input": { @@ -126,7 +126,7 @@ Self-contained HTML report with embedded CSS. Sections: A plain-text paragraph suitable for adaptation into a paper's Methods section. Example: -> Cell type annotation was performed using CellTypePilot (v0.4.0), an evidence-driven +> Cell type annotation was performed using CellTypePilot (v0.4.1), an evidence-driven > annotation pipeline with built-in critic review. Marker gene evidence was sourced from > the CellTypePilot Marker Knowledge Graph (MKG mkg-2026.08.1), a curated atlas integrating > PanglaoDB, CellMarker, and Cell Ontology resources. For each of the N clusters identified diff --git a/src/celltypepilot/__init__.py b/src/celltypepilot/__init__.py index 91215ae..4091833 100644 --- a/src/celltypepilot/__init__.py +++ b/src/celltypepilot/__init__.py @@ -8,7 +8,7 @@ from importlib import import_module -__version__ = "0.4.0" +__version__ = "0.4.1" MKG_VERSION = "mkg-2026.08.1" # Marker Knowledge Graph version __all__ = [ diff --git a/src/celltypepilot/cli.py b/src/celltypepilot/cli.py index 6b34adb..37ee9df 100644 --- a/src/celltypepilot/cli.py +++ b/src/celltypepilot/cli.py @@ -1420,7 +1420,7 @@ def calibration_split( @app.command("governance-freeze-verify") def governance_freeze_verify( freeze: Path = typer.Option( - Path(__file__).parent / "data" / "governance_freeze_v1.json", + Path(__file__).parent / "data" / "governance_freeze_v2.json", "--freeze", help="Frozen governance manifest", ), diff --git a/src/celltypepilot/data/governance_freeze_v1.json b/src/celltypepilot/data/governance_freeze_v2.json similarity index 75% rename from src/celltypepilot/data/governance_freeze_v1.json rename to src/celltypepilot/data/governance_freeze_v2.json index 96c5e1e..f6f0719 100644 --- a/src/celltypepilot/data/governance_freeze_v1.json +++ b/src/celltypepilot/data/governance_freeze_v2.json @@ -1,135 +1,136 @@ { - "schema_version": "celltypepilot.governance-freeze.v1", - "release_id": "celltypepilot-v0.4.0", + "schema_version": "celltypepilot.governance-freeze.v2", + "hash_semantics": "utf8_text_lf_v1", + "release_id": "celltypepilot-v0.4.1", "files": [ { "path": "src/celltypepilot/calibration.py", - "size": 13337, + "normalized_size": 13337, "sha256": "1cfa08da58c6b03ff1e6028c1b5d702b97b88578051519fb6b40b4ebfb2ef338" }, { "path": "src/celltypepilot/calibration_split.py", - "size": 13601, + "normalized_size": 13601, "sha256": "3270edbb49f3660202a446f145bf2d4d51344c711be0e66ae1bdf1b8b526f5dc" }, { "path": "src/celltypepilot/candidate_backends.py", - "size": 13127, + "normalized_size": 13127, "sha256": "5b6120dd9803dbe36ea28da0685253b11d0deb1d5151f864e992932b1db20233" }, { "path": "src/celltypepilot/critic.py", - "size": 33266, + "normalized_size": 33266, "sha256": "13faf74d8d8c0373d4d845f2eb47fd9e3d4b23417b2b00a8b08c8e4991daca78" }, { "path": "src/celltypepilot/data/marker_atlas.json", - "size": 834184, - "sha256": "2aaaab14fc15b5811332baf697a575ac41122d7fb9a4e6f32ea1ed12875449b3" + "normalized_size": 813538, + "sha256": "b1097382b669acdc81ffb9bed77df05d98acf977e1e2a99a14512b54063957ed" }, { "path": "src/celltypepilot/data/packs/gut_ibd_depth_v1/ontology_map.json", - "size": 1018, + "normalized_size": 1018, "sha256": "0eb6c9762285f0f4f607953cb3c154d8b5e223c5cb1dff338d57f9dc285a80b5" }, { "path": "src/celltypepilot/data/packs/gut_ibd_depth_v1/pack.json", - "size": 986, + "normalized_size": 986, "sha256": "89d712fbedb47cb3f141c319907811e3f5d758e83ed7d6c43ad1ab257a639858" }, { "path": "src/celltypepilot/data/packs/gut_ibd_depth_v1/reference_manifest.json", - "size": 445, + "normalized_size": 445, "sha256": "e79bba8098ec1a9c6c86f72f97ac207007396d63e312596c050985e762d6a6fe" }, { "path": "src/celltypepilot/data/packs/lung_evidence_v0_1/marker_atlas.json", - "size": 9852, + "normalized_size": 9852, "sha256": "f6539e227987d1faf10823e561bff10f290fa7c7e3ace4a21704df7558a212e6" }, { "path": "src/celltypepilot/data/packs/lung_evidence_v0_1/ontology_map.json", - "size": 1131, + "normalized_size": 1131, "sha256": "233c8bf42f8611191ffc2ae67cc41a9ed86ee1eacad7b3a3480602eb243c8285" }, { "path": "src/celltypepilot/data/packs/lung_evidence_v0_1/pack.json", - "size": 897, + "normalized_size": 897, "sha256": "64245fcc47560c6f0a88cb7512b37ecc40a01f49f93783e055ea40f06d11e75e" }, { "path": "src/celltypepilot/data/packs/lung_evidence_v0_1/reference_manifest.json", - "size": 453, + "normalized_size": 453, "sha256": "b2c90292b4e984f0b322e20514afee72bc21c4250d78d27fbb0c9728e2c5cc11" }, { "path": "src/celltypepilot/data/packs/premium/marker_atlas.json", - "size": 170227, - "sha256": "dc62461805dcc99aab257ba256e68e2b6034e5642d23bc7f12b4f124acadb81a" + "normalized_size": 165634, + "sha256": "94de0378d806a8462e1f870f0d129866b10870723e8e946776c0219691b131f9" }, { "path": "src/celltypepilot/data/packs/premium/pack.json", - "size": 473, - "sha256": "f310fd5439297cbc033db72059d35e462e9c813d7bbfde3141eef6a001bad797" + "normalized_size": 452, + "sha256": "e392ecfaf7db4a85adf75d1b4d816423ac8530be8a6b9f3287c5287fbe57d2ef" }, { "path": "src/celltypepilot/data/packs/tme_depth_v1/ontology_map.json", - "size": 793, + "normalized_size": 793, "sha256": "cd5cf521863ecae69d6af12edb166cf6be92b0212c2a67f5d847fb26cc2130d2" }, { "path": "src/celltypepilot/data/packs/tme_depth_v1/pack.json", - "size": 982, + "normalized_size": 982, "sha256": "357b3d94e321eba2125aa7486111b90950c1f25b0903f8232aaf792bfa130884" }, { "path": "src/celltypepilot/data/packs/tme_depth_v1/reference_manifest.json", - "size": 467, + "normalized_size": 467, "sha256": "97b99bb3966956c39b9ec57b9c11d07a89fbebee63f6cd5d5b20b4b4ae7810da" }, { "path": "src/celltypepilot/data/state_atlas.json", - "size": 43503, - "sha256": "f3af1ae5a92dcb2e54bd09c0c50122dd16ad4959e8bcfd55cf8962428fd0c42d" + "normalized_size": 41814, + "sha256": "ecf8e00333c89cebba5db03afc03cea9c95a6e8970cb501b58ec0cbc951b4c61" }, { "path": "src/celltypepilot/data/validation_domains.json", - "size": 4814, + "normalized_size": 4814, "sha256": "b5efb42cfc1d11b90f66614824027704da0587d61fdc35093a38b21d7cd8da79" }, { "path": "src/celltypepilot/hierarchical_selector.py", - "size": 12051, + "normalized_size": 12051, "sha256": "5d14cead0f72769b2cf18cf208e922897c6c030c2cb11634e15392eae1b9e8fe" }, { "path": "src/celltypepilot/identity_contract.py", - "size": 15979, + "normalized_size": 15979, "sha256": "53b252b12333ac82e85b0d425dead71eb90c2cd5b9cdff1737227b833846df5a" }, { "path": "src/celltypepilot/lineage_coverage.py", - "size": 9756, + "normalized_size": 9756, "sha256": "c35fe5efccd4b9378c25f5e0dea29807be64e359b5bba711ca6260d9037348ae" }, { "path": "src/celltypepilot/novelty_detector.py", - "size": 13038, + "normalized_size": 13038, "sha256": "a25856085ea7b5074160c36a2427dc6253ca769912e16eea5a77746fa6af2d6e" }, { "path": "src/celltypepilot/pack_manager.py", - "size": 25322, - "sha256": "67eb877aa4a64b601f4332c6c0f75c9b47861c9ba699612f283f7522d65c8cf6" + "normalized_size": 24711, + "sha256": "6545260fd560a7e9a3a027c7cbdf98b5728fdc91cfff5dc8a9f301c93aaaae6e" }, { "path": "src/celltypepilot/state_scorer.py", - "size": 16032, + "normalized_size": 16032, "sha256": "aef926461e52dfa43aa6117ebf75165f116bab55c84cde733bcbb614999987e4" }, { "path": "src/celltypepilot/validation_domains.py", - "size": 4582, + "normalized_size": 4582, "sha256": "f1bc0a888328b9f4bb8a9567a82f5c3e6a4e6d9c45d91c5ad1b8f6585313cf7f" } ], @@ -154,5 +155,5 @@ "human_signoff": "required" }, "claim_boundary": "This freezes software governance and content hashes. It does not freeze or establish biological accuracy, selective-risk calibration, or domain validation.", - "freeze_sha256": "398f5144ff66741595001b6ed265bef5726f1b93c81c442460f855a3f0813b24" + "freeze_sha256": "f6cec7b647bf4afcf0c5b1ed1247b9d4fb62adbd066a3f1bac22af284a47defc" } diff --git a/src/celltypepilot/governance_freeze.py b/src/celltypepilot/governance_freeze.py index 3927d97..9f62890 100644 --- a/src/celltypepilot/governance_freeze.py +++ b/src/celltypepilot/governance_freeze.py @@ -6,19 +6,27 @@ import json from pathlib import Path -GOVERNANCE_FREEZE_SCHEMA = "celltypepilot.governance-freeze.v1" +GOVERNANCE_FREEZE_SCHEMA = "celltypepilot.governance-freeze.v2" +GOVERNANCE_HASH_SEMANTICS = "utf8_text_lf_v1" class GovernanceFreezeError(ValueError): """Raised when a frozen governance artifact is incomplete or changed.""" +def _normalized_content(path: Path) -> bytes: + """Return governed UTF-8 text with platform-independent LF endings.""" + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise GovernanceFreezeError( + f"Governed file must be UTF-8 text for {GOVERNANCE_HASH_SEMANTICS}: {path}" + ) from exc + return text.encode("utf-8") + + def _sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() + return hashlib.sha256(_normalized_content(path)).hexdigest() def _repo_root() -> Path: @@ -78,12 +86,13 @@ def build_governance_freeze( records.append( { "path": path.relative_to(base).as_posix(), - "size": path.stat().st_size, + "normalized_size": len(_normalized_content(path)), "sha256": _sha256(path), } ) payload = { "schema_version": GOVERNANCE_FREEZE_SCHEMA, + "hash_semantics": GOVERNANCE_HASH_SEMANTICS, "release_id": release_id, "files": records, "frozen_invariants": { @@ -127,6 +136,8 @@ def verify_governance_freeze( payload = json.loads(freeze_path.read_text(encoding="utf-8")) if payload.get("schema_version") != GOVERNANCE_FREEZE_SCHEMA: raise GovernanceFreezeError(f"Freeze schema must be {GOVERNANCE_FREEZE_SCHEMA}") + if payload.get("hash_semantics") != GOVERNANCE_HASH_SEMANTICS: + raise GovernanceFreezeError(f"Freeze hash semantics must be {GOVERNANCE_HASH_SEMANTICS}") unsigned = {key: value for key, value in payload.items() if key != "freeze_sha256"} canonical = json.dumps( unsigned, sort_keys=True, separators=(",", ":"), ensure_ascii=False @@ -161,12 +172,17 @@ def verify_governance_freeze( changed = [] for relative, record in expected.items(): path = current_paths[relative] - if path.stat().st_size != record["size"] or _sha256(path) != record["sha256"]: + normalized = _normalized_content(path) + if ( + len(normalized) != record["normalized_size"] + or hashlib.sha256(normalized).hexdigest() != record["sha256"] + ): changed.append(relative) if changed: raise GovernanceFreezeError(f"Governed files changed after freeze: {changed[:5]}") return { "schema_version": GOVERNANCE_FREEZE_SCHEMA, + "hash_semantics": GOVERNANCE_HASH_SEMANTICS, "status": "verified", "release_id": payload["release_id"], "freeze_sha256": payload["freeze_sha256"], diff --git a/tests/test_domain_depth_release.py b/tests/test_domain_depth_release.py index 6f8645e..c7e07c0 100644 --- a/tests/test_domain_depth_release.py +++ b/tests/test_domain_depth_release.py @@ -138,3 +138,24 @@ def test_governance_freeze_detects_tampering(tmp_path): target.write_text(target.read_text(encoding="utf-8") + "\n", encoding="utf-8") with pytest.raises(GovernanceFreezeError, match="changed after freeze"): verify_governance_freeze(freeze, root=copied_root) + + +def test_governance_freeze_is_cross_platform_line_ending_stable(tmp_path): + source_root = REPO_ROOT + copied_root = tmp_path / "repo" + for source in governed_paths(source_root): + destination = copied_root / source.relative_to(source_root) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + target = copied_root / "src/celltypepilot/pack_manager.py" + lf_content = target.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + target.write_bytes(lf_content) + freeze = copied_root / "freeze.json" + payload = build_governance_freeze(freeze, root=copied_root, release_id="cross-platform-test") + + target.write_bytes(lf_content.replace(b"\n", b"\r\n")) + verified = verify_governance_freeze(freeze, root=copied_root) + + assert payload["hash_semantics"] == "utf8_text_lf_v1" + assert verified["status"] == "verified"