diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 39cfdbc..0b2cebc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "celltypepilot", - "version": "0.3.0", + "version": "0.3.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 884c9ed..bcdc89e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "celltypepilot", - "version": "0.3.0", + "version": "0.3.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 275665e..4d6bcdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,19 +41,15 @@ jobs: --tag "$GITHUB_REF_NAME" \ --output-dir release-assets python -m twine check dist/* - ( - cd dist - sha256sum * - ) > ../release-assets/SHA256SUMS - ( - cd release-assets - sha256sum celltypepilot-plugin-*.zip - ) >> SHA256SUMS + python scripts/build_release_checksums.py \ + --dist-dir dist \ + --plugin-dir release-assets \ + --output release-assets/SHA256SUMS - name: Run release verification suite run: | - ruff check src/ tests/ scripts/build_plugin_bundle.py - ruff format --check src/ tests/ scripts/build_plugin_bundle.py + ruff check src/ tests/ scripts/build_plugin_bundle.py scripts/build_release_checksums.py + ruff format --check src/ tests/ scripts/build_plugin_bundle.py scripts/build_release_checksums.py pytest --cov=celltypepilot --cov-report=term-missing - name: Smoke-test installed wheel and plugin bundle diff --git a/CHANGELOG.md b/CHANGELOG.md index 4492158..00b5f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.3.1] - 2026-08-10 + +### Fixed + +- Generate release checksums with a tested, cross-platform Python builder instead of shell + redirections whose paths depended on the parent shell working directory. +- Fail closed unless the current version has exactly one wheel, one source distribution, and one + Agent plugin bundle before `SHA256SUMS` is written. + +### Release note + +- The immutable `v0.3.0` tag did not create a GitHub Release or publish a PyPI distribution because + its verification job stopped before either publication stage. +- This patch changes release infrastructure and version metadata only; it adds no biological + validation or annotation-accuracy claim. + ## [0.3.0] - 2026-08-10 ### Added @@ -30,4 +46,5 @@ 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.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 33cc31b..cae9580 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ coding workspace you already use. A qualified human owns the final biological de ## Current validation boundary -| Available in v0.3.0 | Not yet claimed | +| Available in v0.3.1 | Not yet claimed | |---|---| | Direction-, log2FC-, FDR-, and expression-fraction-gated DE evidence | Biological superiority over CellTypist, SingleR, Azimuth, or popV | | Complete expected-marker denominators, with missing genes separated from present-but-silent genes | A completed public multi-study/donor benchmark | @@ -80,7 +80,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.3.0..." +├── methodology_draft.txt # "We annotated N clusters using CellTypePilot v0.3.1..." ├── manifest.json # Provenance: versions, params, data hash, output hashes └── figures/ ├── umap_cluster.png # UMAP by cluster (colorblind-friendly Wong palette) @@ -321,9 +321,9 @@ reference, evidence, and provenance gates apply as the CLI. ``` CellTypePilot/ ├── .claude-plugin/ -│ └── plugin.json ← Claude Code plugin manifest (v0.3.0) +│ └── plugin.json ← Claude Code plugin manifest (v0.3.1) ├── .codex-plugin/ -│ └── plugin.json ← Codex plugin manifest (v0.3.0, with interface block) +│ └── plugin.json ← Codex plugin manifest (v0.3.1, with interface block) ├── skills/ │ └── celltypepilot/ │ ├── SKILL.md ← Shared skill instructions (4-stage workflow) diff --git a/pyproject.toml b/pyproject.toml index 8957851..b4e8fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "celltypepilot" -version = "0.3.0" +version = "0.3.1" description = "Deterministic single-cell annotation backend for Agent plugins" readme = "README.md" license = "MIT" diff --git a/scripts/build_release_checksums.py b/scripts/build_release_checksums.py new file mode 100644 index 0000000..30e8e40 --- /dev/null +++ b/scripts/build_release_checksums.py @@ -0,0 +1,83 @@ +"""Build a deterministic SHA256SUMS file for CellTypePilot release artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_PATTERN = re.compile(r'^version = "([^"]+)"$', re.MULTILINE) + + +def _read_version(root: Path = REPO_ROOT) -> str: + pyproject = (root / "pyproject.toml").read_text(encoding="utf-8") + match = VERSION_PATTERN.search(pyproject) + if not match: + raise ValueError("project version not found in pyproject.toml") + return match.group(1) + + +def _require_single(directory: Path, pattern: str, label: str) -> Path: + matches = sorted(path for path in directory.glob(pattern) if path.is_file()) + if len(matches) != 1: + raise ValueError( + f"expected exactly one {label} matching {directory / pattern}, found {len(matches)}" + ) + return matches[0] + + +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() + + +def build_checksums( + dist_dir: Path, + plugin_dir: Path, + output_path: Path, + *, + root: Path = REPO_ROOT, +) -> Path: + """Hash the one wheel, sdist, and plugin bundle for the current project version.""" + version = _read_version(root) + artifacts = [ + _require_single(dist_dir, f"celltypepilot-{version}-*.whl", "wheel"), + _require_single(dist_dir, f"celltypepilot-{version}.tar.gz", "source distribution"), + _require_single( + plugin_dir, + f"celltypepilot-plugin-{version}.zip", + "Agent plugin bundle", + ), + ] + + names = [artifact.name for artifact in artifacts] + if len(names) != len(set(names)): + raise ValueError("release artifact basenames must be unique") + + output_path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{_sha256(artifact)} {artifact.name}\n" for artifact in sorted(artifacts)] + with output_path.open("w", encoding="utf-8", newline="\n") as handle: + handle.writelines(lines) + return output_path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist-dir", type=Path, default=Path("dist")) + parser.add_argument("--plugin-dir", type=Path, default=Path("release-assets")) + parser.add_argument( + "--output", + type=Path, + default=Path("release-assets/SHA256SUMS"), + ) + args = parser.parse_args() + print(build_checksums(args.dist_dir, args.plugin_dir, args.output)) + + +if __name__ == "__main__": + main() diff --git a/skills/celltypepilot/reference/outputs.md b/skills/celltypepilot/reference/outputs.md index 9de7022..6b39bda 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.3.0", + "celltypepilot_version": "0.3.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.3.0), an evidence-driven +> Cell type annotation was performed using CellTypePilot (v0.3.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 671c848..958db34 100644 --- a/src/celltypepilot/__init__.py +++ b/src/celltypepilot/__init__.py @@ -8,7 +8,7 @@ from importlib import import_module -__version__ = "0.3.0" +__version__ = "0.3.1" MKG_VERSION = "mkg-2026.08.1" # Marker Knowledge Graph version __all__ = [ diff --git a/src/celltypepilot/literature.py b/src/celltypepilot/literature.py index 28c876c..c8e6693 100644 --- a/src/celltypepilot/literature.py +++ b/src/celltypepilot/literature.py @@ -15,6 +15,10 @@ import urllib.request from dataclasses import dataclass, field +from . import __version__ + +USER_AGENT = f"CellTypePilot/{__version__} (https://github.com/HERRY423/CellTypePilot)" + # ────────────────────────────────────────────── # Data classes # ────────────────────────────────────────────── @@ -102,7 +106,7 @@ def search_pubmed( } search_url = f"{PUBMED_BASE}/esearch.fcgi?{urllib.parse.urlencode(search_params)}" - req = urllib.request.Request(search_url, headers={"User-Agent": "CellTypePilot/0.3.0"}) + req = urllib.request.Request(search_url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=10) as resp: search_data = json.loads(resp.read().decode("utf-8")) @@ -119,7 +123,7 @@ def search_pubmed( } fetch_url = f"{PUBMED_BASE}/efetch.fcgi?{urllib.parse.urlencode(fetch_params)}" - req = urllib.request.Request(fetch_url, headers={"User-Agent": "CellTypePilot/0.3.0"}) + req = urllib.request.Request(fetch_url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=10) as resp: # efetch returns XML, but we can parse basic info from esummary pass @@ -133,7 +137,7 @@ def search_pubmed( } summary_url = f"{PUBMED_BASE}/esummary.fcgi?{urllib.parse.urlencode(summary_params)}" - req = urllib.request.Request(summary_url, headers={"User-Agent": "CellTypePilot/0.3.0"}) + req = urllib.request.Request(summary_url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=10) as resp: summary_data = json.loads(resp.read().decode("utf-8")) @@ -200,9 +204,7 @@ def search_biorxiv( req = urllib.request.Request( url, - headers={ - "User-Agent": "CellTypePilot/0.3.0 (https://github.com/HERRY423/CellTypePilot)" - }, + headers={"User-Agent": USER_AGENT}, ) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) @@ -454,7 +456,7 @@ def check_mcp_availability() -> dict: test_query = "test" params = {"db": "pubmed", "term": test_query, "retmax": "1", "retmode": "json"} url = f"{PUBMED_BASE}/esearch.fcgi?{urllib.parse.urlencode(params)}" - req = urllib.request.Request(url, headers={"User-Agent": "CellTypePilot/0.3.0"}) + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=5) as resp: data = json.loads(resp.read().decode("utf-8")) if "esearchresult" in data: diff --git a/src/celltypepilot/ontology.py b/src/celltypepilot/ontology.py index c57a050..4f9b4c7 100644 --- a/src/celltypepilot/ontology.py +++ b/src/celltypepilot/ontology.py @@ -32,11 +32,13 @@ from datetime import datetime, timezone from pathlib import Path +from . import __version__ + ONTOLOGY_ENV_VAR = "CELLTYPEPILOT_ONTOLOGY_DIR" ONTOLOGY_FILENAME = "cl.obo" METADATA_FILENAME = "ontology_meta.json" CL_OBO_URL = "http://purl.obolibrary.org/obo/cl.obo" -USER_AGENT = "CellTypePilot/0.3.0 (https://github.com/HERRY423/CellTypePilot)" +USER_AGENT = f"CellTypePilot/{__version__} (https://github.com/HERRY423/CellTypePilot)" class OntologyError(ValueError): diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index eee25ed..bff8afb 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -11,6 +11,14 @@ REPO_ROOT = Path(__file__).parents[1] BUNDLE_SCRIPT = REPO_ROOT / "scripts/build_plugin_bundle.py" +CHECKSUM_SCRIPT = REPO_ROOT / "scripts/build_release_checksums.py" + + +def _project_version() -> str: + for line in (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8").splitlines(): + if line.startswith('version = "'): + return line.split('"', 2)[1] + raise AssertionError("project version not found") def test_pyproject_keeps_extras_out_of_project_urls(): @@ -26,8 +34,16 @@ def test_pyproject_keeps_extras_out_of_project_urls(): def test_plugin_bundle_contains_agent_surface_and_installable_backend(tmp_path): + version = _project_version() result = subprocess.run( - [sys.executable, str(BUNDLE_SCRIPT), "--output-dir", str(tmp_path), "--tag", "v0.3.0"], + [ + sys.executable, + str(BUNDLE_SCRIPT), + "--output-dir", + str(tmp_path), + "--tag", + f"v{version}", + ], check=True, capture_output=True, text=True, @@ -35,7 +51,7 @@ def test_plugin_bundle_contains_agent_surface_and_installable_backend(tmp_path): bundle = Path(result.stdout.strip()) assert bundle.is_file() - prefix = "celltypepilot-plugin-0.3.0/" + prefix = f"celltypepilot-plugin-{version}/" with zipfile.ZipFile(bundle) as archive: names = set(archive.namelist()) required = { @@ -53,7 +69,7 @@ def test_plugin_bundle_contains_agent_surface_and_installable_backend(tmp_path): manifest = json.loads(archive.read(prefix + "BUNDLE_MANIFEST.json")) assert manifest["schema_version"] == "celltypepilot.plugin-bundle.v1" - assert manifest["version"] == "0.3.0" + assert manifest["version"] == version assert manifest["distribution"] == "agent_plugin_bundle" for record in manifest["files"]: payload = archive.read(prefix + record["path"]) @@ -62,6 +78,7 @@ def test_plugin_bundle_contains_agent_surface_and_installable_backend(tmp_path): def test_plugin_bundle_rejects_mismatched_release_tag(tmp_path): + version = _project_version() result = subprocess.run( [sys.executable, str(BUNDLE_SCRIPT), "--output-dir", str(tmp_path), "--tag", "v9.9.9"], check=False, @@ -69,4 +86,80 @@ def test_plugin_bundle_rejects_mismatched_release_tag(tmp_path): text=True, ) assert result.returncode != 0 - assert "does not match project version v0.3.0" in result.stderr + assert f"does not match project version v{version}" in result.stderr + + +def test_release_checksums_cover_exact_versioned_artifacts(tmp_path): + version = _project_version() + dist_dir = tmp_path / "dist" + plugin_dir = tmp_path / "release-assets" + dist_dir.mkdir() + plugin_dir.mkdir() + artifacts = { + dist_dir / f"celltypepilot-{version}-py3-none-any.whl": b"wheel payload", + dist_dir / f"celltypepilot-{version}.tar.gz": b"sdist payload", + plugin_dir / f"celltypepilot-plugin-{version}.zip": b"plugin payload", + } + for path, payload in artifacts.items(): + path.write_bytes(payload) + + output_path = plugin_dir / "SHA256SUMS" + result = subprocess.run( + [ + sys.executable, + str(CHECKSUM_SCRIPT), + "--dist-dir", + str(dist_dir), + "--plugin-dir", + str(plugin_dir), + "--output", + str(output_path), + ], + check=True, + capture_output=True, + text=True, + ) + + assert Path(result.stdout.strip()) == output_path + expected = [ + f"{hashlib.sha256(payload).hexdigest()} {path.name}" + for path, payload in sorted(artifacts.items()) + ] + assert output_path.read_text(encoding="utf-8").splitlines() == expected + assert all("dist" not in line and "release-assets" not in line for line in expected) + + +def test_release_checksums_fail_closed_when_artifact_is_missing(tmp_path): + version = _project_version() + dist_dir = tmp_path / "dist" + plugin_dir = tmp_path / "release-assets" + dist_dir.mkdir() + plugin_dir.mkdir() + (dist_dir / f"celltypepilot-{version}.tar.gz").write_bytes(b"sdist payload") + (plugin_dir / f"celltypepilot-plugin-{version}.zip").write_bytes(b"plugin payload") + + result = subprocess.run( + [ + sys.executable, + str(CHECKSUM_SCRIPT), + "--dist-dir", + str(dist_dir), + "--plugin-dir", + str(plugin_dir), + "--output", + str(plugin_dir / "SHA256SUMS"), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "expected exactly one wheel" in result.stderr + + +def test_release_workflow_uses_tested_checksum_builder(): + workflow = (REPO_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + assert "python scripts/build_release_checksums.py" in workflow + assert "> ../release-assets/SHA256SUMS" not in workflow + assert ") >> SHA256SUMS" not in workflow diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 000d91e..0919a07 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -211,6 +211,7 @@ class TestProvenance: """Tests for provenance tracking.""" def test_create_and_save_manifest(self): + from celltypepilot import __version__ from celltypepilot.provenance import create_manifest, load_manifest, save_manifest with tempfile.TemporaryDirectory() as tmpdir: @@ -223,7 +224,7 @@ def test_create_and_save_manifest(self): parameters={"embedding_key": "X_umap"}, output_dir=tmpdir, ) - assert manifest["celltypepilot_version"] == "0.3.0" + assert manifest["celltypepilot_version"] == __version__ assert manifest["mkg_version"] == "mkg-2026.08.1" path = save_manifest(manifest, tmpdir) @@ -246,12 +247,13 @@ def test_doctor_command(self): def test_version(self): from typer.testing import CliRunner + from celltypepilot import __version__ from celltypepilot.cli import app runner = CliRunner() result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert "0.3.0" in result.output + assert __version__ in result.output def test_markers_command(self): from typer.testing import CliRunner