name: Publish to PyPI # Publish claude-ctx to PyPI on every tag that looks like a version # (v0.5.0, v0.5.0-rc1, v1.0.0, …). Uses PyPI Trusted Publishing — # no API token needed once the pending publisher is configured at # https://pypi.org/manage/account/publishing/. # # Also deployable on demand to TestPyPI via workflow_dispatch. Production # PyPI publishes must use a version tag push so graph release assets are # uploaded before the wheel is published. 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: # noqa: S310 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 # required for Trusted Publishing 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/