| name: Publish to PyPI |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| on: |
| push: |
| tags: |
| - "v*" |
| workflow_dispatch: |
| inputs: |
| repository: |
| description: "PyPI repository (pypi or testpypi)" |
| required: true |
| default: "pypi" |
| type: choice |
| options: |
| - pypi |
| - testpypi |
|
|
| env: |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" |
|
|
| permissions: |
| contents: read |
|
|
| jobs: |
| build: |
| name: Build sdist + wheel |
| runs-on: ubuntu-latest |
| permissions: |
| contents: read |
| outputs: |
| graph_assets_available: ${{ steps.resolve_graph_assets.outcome == 'success' }} |
| steps: |
| - name: Checkout |
| uses: actions/checkout@v5 |
| with: |
| lfs: false |
|
|
| - name: Set up Python |
| uses: actions/setup-python@v6 |
| with: |
| python-version: "3.11" |
|
|
| - name: Install release tooling |
| run: | |
| python -m pip install --upgrade pip |
| python -m pip install ".[dev]" build packaging twine |
| |
| - name: Validate release target |
| run: | |
| python - <<'PY' |
| import os |
| import tomllib |
| from packaging.version import Version |
| |
| event = os.environ["GITHUB_EVENT_NAME"] |
| target_repository = os.environ.get("INPUT_REPOSITORY", "pypi") |
| ref_type = os.environ.get("GITHUB_REF_TYPE", "") |
| ref_name = os.environ["GITHUB_REF_NAME"] |
|
|
| if event == "workflow_dispatch" and target_repository == "pypi": |
| raise SystemExit( |
| "Manual PyPI publish is disabled; push a version tag so " |
| "graph release assets are uploaded before publishing" |
| ) |
| if event == "workflow_dispatch" and target_repository == "testpypi": |
| print(f"manual TestPyPI publish allowed from {ref_type}:{ref_name}") |
| raise SystemExit(0) |
|
|
| if ref_type != "tag": |
| raise SystemExit( |
| "PyPI release must run from a version tag; " |
| f"got {ref_type}:{ref_name}" |
| ) |
| tag = ref_name |
| if not tag.startswith("v"): |
| raise SystemExit(f"release tag must start with v: {tag}") |
| |
| with open("pyproject.toml", "rb") as fh: |
| package_version = tomllib.load(fh)["project"]["version"] |
|
|
| tag_version = str(Version(tag[1:])) |
| normalized_package_version = str(Version(package_version)) |
| if tag_version != normalized_package_version: |
| raise SystemExit( |
| f"tag {tag!r} does not match pyproject version {package_version!r}" |
| ) |
| print(f"release version {package_version} matches tag {tag}") |
| PY |
| env: |
| INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }} |
| |
| - name: Reject already published PyPI version |
| run: | |
| python - <<'PY' |
| import os |
| import tomllib |
| import urllib.error |
| import urllib.request |
| |
| target_repository = os.environ.get("INPUT_REPOSITORY", "pypi") |
| if target_repository != "pypi": |
| print(f"skipping PyPI reuse check for {target_repository}") |
| raise SystemExit(0) |
| |
| with open("pyproject.toml", "rb") as fh: |
| project = tomllib.load(fh)["project"] |
|
|
| name = project["name"] |
| package_version = project["version"] |
| url = f"https://pypi.org/pypi/{name}/{package_version}/json" |
| try: |
| with urllib.request.urlopen(url, timeout=15): |
| raise SystemExit( |
| f"{name} {package_version} already exists on PyPI; " |
| "bump the version before publishing" |
| ) |
| except urllib.error.HTTPError as exc: |
| if exc.code == 404: |
| print(f"{name} {package_version} is not present on PyPI") |
| raise SystemExit(0) |
| raise |
| PY |
| env: |
| INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }} |
|
|
| - name: Resolve release graph artifacts from release assets |
| id: resolve_graph_assets |
| env: |
| GH_TOKEN: ${{ github.token }} |
| TAG_NAME: ${{ github.ref_name }} |
| run: | |
| set -euo pipefail |
| echo "Resolving graph artifacts from matching release assets to avoid Git LFS bandwidth." |
| python - <<'PY' |
| import hashlib |
| import json |
| import os |
| from pathlib import Path |
| import subprocess |
| import time |
| import urllib.request |
| |
| repo = os.environ["GITHUB_REPOSITORY"] |
| current_tag = os.environ.get("TAG_NAME", "") |
| release_asset_wait_seconds = 300 |
| release_asset_poll_seconds = 10 |
| expected_graph_assets = { |
| "graph/wiki-graph.tar.gz": { |
| "sha256": "e487ec2109803e3c05cb2ca6906e8a0bae681f32a4fe79f3fb2f168fbea2c947", |
| "size": 329276669, |
| }, |
| "graph/wiki-graph-runtime.tar.gz": { |
| "sha256": "993fc08377fdb09edcff4414c59b10fc121189b4a161bf796e3f8f6600907bb1", |
| "size": 122141091, |
| }, |
| } |
|
|
| def load_releases() -> list[dict]: |
| return json.loads(subprocess.check_output( |
| ["gh", "api", f"repos/{repo}/releases?per_page=50"], |
| text=True, |
| )) |
|
|
| def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None: |
| graph_tar = Path(path_name) |
| fallback = expected_graph_assets[path_name] |
| expected_oid = fallback["sha256"] |
| expected_size = int(fallback["size"]) |
| if graph_tar.exists(): |
| pointer = graph_tar.read_text(encoding="utf-8", errors="replace") |
| for line in pointer.splitlines(): |
| if line.startswith("oid sha256:"): |
| expected_oid = line.split(":", 1)[1].strip() |
| elif line.startswith("size "): |
| expected_size = int(line.split(" ", 1)[1].strip()) |
| if not pointer.startswith("version https://git-lfs.github.com/spec/v1") and graph_tar.stat().st_size > hydrated_min_size: |
| print(f"{graph_tar} is already hydrated") |
| return |
|
|
| deadline = time.monotonic() + release_asset_wait_seconds |
| while True: |
| candidates = [] |
| for release in load_releases(): |
| tag_name = str(release.get("tag_name") or "") |
| is_graph_cache = tag_name.startswith("graph-artifacts-") |
| if release.get("draft") or ( |
| release.get("prerelease") and not is_graph_cache |
| ): |
| continue |
| if tag_name == current_tag: |
| continue |
| for asset in release.get("assets", []): |
| if asset.get("name") != graph_tar.name: |
| continue |
| digest = str(asset.get("digest") or "") |
| size = int(asset.get("size") or 0) |
| if size != expected_size: |
| continue |
| if digest and digest != f"sha256:{expected_oid}": |
| continue |
| candidates.append((tag_name, asset)) |
|
|
| if candidates: |
| break |
| if time.monotonic() >= deadline: |
| raise SystemExit( |
| f"No previous release asset matches {path_name} " |
| f"sha256:{expected_oid} size:{expected_size}" |
| ) |
| print( |
| f"Waiting for matching release asset {graph_tar.name} " |
| f"sha256:{expected_oid} size:{expected_size}" |
| ) |
| time.sleep(release_asset_poll_seconds) |
|
|
| source_tag, asset = candidates[0] |
| tmp = graph_tar.with_name(f"{graph_tar.name}.download") |
| sha = hashlib.sha256() |
| total = 0 |
| with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: |
| with tmp.open("wb") as fh: |
| while True: |
| chunk = resp.read(1024 * 1024) |
| if not chunk: |
| break |
| sha.update(chunk) |
| total += len(chunk) |
| fh.write(chunk) |
| actual_oid = sha.hexdigest() |
| if actual_oid != expected_oid or total != expected_size: |
| tmp.unlink(missing_ok=True) |
| raise SystemExit( |
| f"Downloaded {path_name} does not match LFS pointer: " |
| f"sha256:{actual_oid} size:{total}" |
| ) |
| tmp.replace(graph_tar) |
| print( |
| f"Hydrated {path_name} from {source_tag} release asset " |
| f"sha256:{actual_oid} size:{total}" |
| ) |
|
|
| hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000) |
| hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000) |
| PY |
|
|
| - name: Validate release graph artifacts |
| if: steps.resolve_graph_assets.outcome == 'success' |
| run: | |
| python src/validate_graph_artifacts.py \ |
| --graph-dir graph \ |
| --deep \ |
| --min-nodes 79000 \ |
| --min-edges 1700000 \ |
| --min-skills-sh-nodes 67000 \ |
| --min-semantic-edges 1000000 \ |
| --expected-nodes 79958 \ |
| --expected-edges 1778069 \ |
| --expected-semantic-edges 1088763 \ |
| --expected-harness-nodes 207 \ |
| --expected-skills-sh-nodes 67028 \ |
| --expected-skills-sh-catalog-entries 67024 \ |
| --expected-skills-sh-converted 67024 \ |
| --expected-skill-pages 68494 \ |
| --expected-agent-pages 467 \ |
| --expected-mcp-pages 10790 \ |
| --expected-harness-pages 207 \ |
| --line-threshold 180 \ |
| --max-stage-lines 40 |
| |
| - name: Validate README and docs stats |
| if: steps.resolve_graph_assets.outcome == 'success' |
| run: python src/update_repo_stats.py --check |
|
|
| - name: Static gates |
| run: | |
| python -m ruff check src hooks scripts |
| python -m mypy src |
| |
| - name: Clean-host contract |
| run: python scripts/clean_host_contract.py --fast |
|
|
| - name: Release canary tests |
| run: | |
| python -m pytest -q --no-cov \ |
| src/tests/test_ci_classifier.py \ |
| src/tests/test_package_scaffold.py \ |
| src/tests/test_clean_host_contract.py \ |
| src/tests/test_alive_loop_e2e.py |
| |
| - name: Build distributions |
| run: python -m build |
|
|
| - name: Check distributions |
| run: python -m twine check dist/* |
|
|
| - name: Check distribution contents |
| run: | |
| python - <<'PY' |
| import tarfile |
| import zipfile |
| from pathlib import Path |
| |
| wheel = next(Path("dist").glob("*.whl")) |
| sdist = next(Path("dist").glob("*.tar.gz")) |
|
|
| with zipfile.ZipFile(wheel) as zf: |
| wheel_names = set(zf.namelist()) |
| required = {"ctx/config.json", "ctx/skill-registry.json"} |
| missing = sorted(required - wheel_names) |
| if missing: |
| raise SystemExit(f"wheel missing packaged defaults: {missing}") |
| |
| with tarfile.open(sdist, "r:gz") as tf: |
| names = set(tf.getnames()) |
| forbidden = ("/src/tests/", "/.claude/", "/.a5c/") |
| leaked = sorted(name for name in names if any(part in name for part in forbidden)) |
| if leaked: |
| raise SystemExit("sdist contains local/test artifacts:\n" + "\n".join(leaked[:20])) |
| print(f"checked distribution contents: {wheel.name}, {sdist.name}") |
| PY |
| |
| - name: Smoke install wheel |
| run: | |
| python -m venv .venv-smoke |
| . .venv-smoke/bin/activate |
| python -m pip install --upgrade pip |
| wheel="$(python - <<'PY' |
| from pathlib import Path |
| print(next(Path("dist").glob("*.whl"))) |
| PY |
| )" |
| python -m pip install "$wheel" |
| python -m pip check |
| python - <<'PY' |
| import tomllib |
| from importlib.metadata import entry_points, version |
|
|
| import ctx |
| import ctx_config |
|
|
| with open("pyproject.toml", "rb") as fh: |
| expected_scripts = set(tomllib.load(fh)["project"]["scripts"]) |
|
|
| dist_version = version("claude-ctx") |
| if ctx.__version__ != dist_version: |
| raise SystemExit( |
| f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}" |
| ) |
| if ctx_config.cfg.recommendation_top_k != 5: |
| raise SystemExit("packaged default config was not loaded") |
| scripts = { |
| ep.name: ep for ep in entry_points(group="console_scripts") |
| if ep.name == "ctx" or ep.name.startswith("ctx-") |
| } |
| missing = sorted(expected_scripts - set(scripts)) |
| extra = sorted(set(scripts) - expected_scripts) |
| if missing or extra: |
| raise SystemExit( |
| "wheel console-script surface mismatch\n" |
| f"missing: {missing}\n" |
| f"extra: {extra}" |
| ) |
| failures = [] |
| for ep in scripts.values(): |
| try: |
| ep.load() |
| except Exception as exc: |
| failures.append(f"{ep.name}: {exc!r}") |
| if failures: |
| raise SystemExit("console script load failures:\n" + "\n".join(failures)) |
| unsafe_help = {"ctx-mcp-server"} |
| safe_help = sorted(expected_scripts - unsafe_help) |
| with open("ctx-console-help.txt", "w", encoding="utf-8", newline="\n") as fh: |
| fh.write("\n".join(safe_help) + "\n") |
| print( |
| f"loaded {len(scripts)} ctx console scripts from wheel " |
| f"{dist_version}; help-smoke={len(safe_help)}" |
| ) |
| PY |
| while IFS= read -r cmd; do |
| cmd="${cmd%$'\r'}" |
| [[ -z "$cmd" ]] && continue |
| echo "help smoke: $cmd" |
| "$cmd" --help >/dev/null |
| done < ctx-console-help.txt |
| python -m pip install "${wheel}[harness]" |
| python - <<'PY' |
| import litellm |
|
|
| print(f"harness extra import ok: litellm {getattr(litellm, '__version__', 'unknown')}") |
| PY |
| |
| - name: Upload dist artifact |
| uses: actions/upload-artifact@v7 |
| with: |
| name: dist |
| path: dist/ |
| |
| - name: Upload graph artifact bundle |
| if: steps.resolve_graph_assets.outcome == 'success' |
| uses: actions/upload-artifact@v7 |
| with: |
| name: graph-release-assets |
| path: | |
| graph/wiki-graph.tar.gz |
| graph/wiki-graph-runtime.tar.gz |
| graph/skills-sh-catalog.json.gz |
| graph/communities.json |
| graph/entity-overlays.jsonl |
| |
| release-assets: |
| name: Upload graph release assets |
| needs: build |
| if: github.event_name == 'push' && needs.build.outputs.graph_assets_available == 'true' |
| runs-on: ubuntu-latest |
| permissions: |
| contents: write |
| steps: |
| - name: Download graph artifact bundle |
| uses: actions/download-artifact@v7 |
| with: |
| name: graph-release-assets |
| path: graph-release-assets |
| |
| - name: Upload graph assets to GitHub release |
| env: |
| GH_TOKEN: ${{ github.token }} |
| TAG_NAME: ${{ github.ref_name }} |
| run: | |
| gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ |
| || gh release create "$TAG_NAME" \ |
| --repo "$GITHUB_REPOSITORY" \ |
| --title "$TAG_NAME" \ |
| --notes "ctx $TAG_NAME" |
| gh release upload "$TAG_NAME" \ |
| --repo "$GITHUB_REPOSITORY" \ |
| graph-release-assets/wiki-graph.tar.gz \ |
| graph-release-assets/wiki-graph-runtime.tar.gz \ |
| graph-release-assets/skills-sh-catalog.json.gz \ |
| graph-release-assets/communities.json \ |
| graph-release-assets/entity-overlays.jsonl \ |
| --clobber |
|
|
| publish: |
| name: Publish to PyPI |
| needs: |
| - build |
| - release-assets |
| if: ${{ always() && needs.build.result == 'success' && (needs.release-assets.result == 'success' || (github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi')) }} |
| runs-on: ubuntu-latest |
| permissions: |
| contents: read |
| id-token: write |
| environment: |
| name: pypi |
| url: https://pypi.org/project/claude-ctx/ |
| steps: |
| - name: Download dist artifact |
| uses: actions/download-artifact@v7 |
| with: |
| name: dist |
| path: dist/ |
|
|
| - name: Publish to PyPI |
| if: github.event_name == 'push' |
| uses: pypa/gh-action-pypi-publish@release/v1 |
|
|
| - name: Publish to TestPyPI |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi' |
| uses: pypa/gh-action-pypi-publish@release/v1 |
| with: |
| repository-url: https://test.pypi.org/legacy/ |
|
|