Repository navigation
diffctx CD #82
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .github/workflows/cd.yml | |
| name: diffctx CD | |
| permissions: {} | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| 'on': | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version to release (e.g., 1.6.1)' | |
| required: true | |
| publish_to_pypi: | |
| description: 'Publish to PyPI' | |
| required: true | |
| default: 'false' | |
| type: choice | |
| options: | |
| - 'true' | |
| - 'false' | |
| jobs: | |
| prepare-version: | |
| name: Prepare Version Commit | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.set_outputs.outputs.version }} | |
| tag_name: ${{ steps.set_outputs.outputs.tag_name }} | |
| commit_sha: ${{ steps.commit_version.outputs.commit_sha }} | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Check that we're on main branch | |
| env: | |
| CURRENT_BRANCH: ${{ github.ref_name }} | |
| run: | | |
| if [ "$CURRENT_BRANCH" != "main" ]; then | |
| echo "Error: Releases can only be created from the main branch. Current branch: $CURRENT_BRANCH" | |
| exit 1 | |
| fi | |
| - name: Validate version format (PEP 440) | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.dev[0-9]+)?(\.post[0-9]+)?$ ]]; then | |
| echo "Error: Invalid version format '$VERSION'." | |
| echo "Expected PEP 440: 1.0.0, 1.0.0a1, 1.0.0b1, 1.0.0rc1, 1.0.0.dev1, 1.0.0.post1" | |
| exit 1 | |
| fi | |
| echo "Version format valid (PEP 440): $VERSION" | |
| - name: Check version is not already set | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| CURRENT=$(python - <<'PY' | |
| import re | |
| content = open('src/diffctx/version.py').read() | |
| m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) | |
| print(m.group(1) if m else '') | |
| PY | |
| ) | |
| if [ "$CURRENT" = "$VERSION" ]; then | |
| echo "Error: version.py already contains version $VERSION. Nothing to release." | |
| exit 1 | |
| fi | |
| echo "Current version: $CURRENT -> New version: $VERSION" | |
| - name: Set version in version.py and pyproject.toml | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| echo "Setting version to $VERSION" | |
| python - <<'PY' | |
| import json, os, re, pathlib | |
| ver = os.environ["VERSION"] | |
| p = pathlib.Path("src/diffctx/version.py") | |
| s = p.read_text(encoding="utf-8") | |
| s, n = re.subn(r'__version__\s*=\s*["\'].*?["\']', f'__version__ = "{ver}"', s, count=1) | |
| assert n == 1, "version.py: __version__ assignment not found" | |
| p.write_text(s, encoding="utf-8") | |
| py = pathlib.Path("pyproject.toml") | |
| s = py.read_text(encoding="utf-8") | |
| s, n = re.subn(r'(?m)^version\s*=\s*["\'][^"\']+["\']', f'version = "{ver}"', s, count=1) | |
| assert n == 1, "pyproject.toml: [project].version not found" | |
| py.write_text(s, encoding="utf-8") | |
| cargo = pathlib.Path("crates/diffctx-native/Cargo.toml") | |
| s = cargo.read_text(encoding="utf-8") | |
| s, n = re.subn(r'(?m)^version\s*=\s*"[^"]+"', f'version = "{ver}"', s, count=1) | |
| assert n == 1, "Cargo.toml: [package].version not found" | |
| cargo.write_text(s, encoding="utf-8") | |
| # The Action's default pin and the plugin manifest are consumer-facing | |
| # versions: left unbumped, Marketplace and plugin users stay on the | |
| # previous release forever while every other channel moves. | |
| act = pathlib.Path("action.yml") | |
| s = act.read_text(encoding="utf-8") | |
| s, n = re.subn(r'(?m)^(\s*default:\s*)\d+\.\d+\.\d+\s*$', rf'\g<1>{ver}', s, count=1) | |
| assert n == 1, "action.yml: diffctx-version default not found" | |
| act.write_text(s, encoding="utf-8") | |
| plug = pathlib.Path(".claude-plugin/plugin.json") | |
| doc = json.loads(plug.read_text(encoding="utf-8")) | |
| doc["version"] = ver | |
| plug.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") | |
| srv = pathlib.Path("server.json") | |
| doc = json.loads(srv.read_text(encoding="utf-8")) | |
| doc["version"] = ver | |
| for pkg in doc["packages"]: | |
| pkg["version"] = ver | |
| srv.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") | |
| # The action doc pins `nikolay-e/diffctx@v<semver>` in its examples; | |
| # test_version_consistency gates them against __version__, so a | |
| # release that skips this file fails the next full pytest run. | |
| doc_md = pathlib.Path("docs/product/github-action.md") | |
| s = doc_md.read_text(encoding="utf-8") | |
| s, n = re.subn(r"(nikolay-e/diffctx@v)\d+\.\d+\.\d+", r"\g<1>" + ver, s) | |
| assert n >= 2, "github-action.md: expected at least 2 version pins" | |
| doc_md.write_text(s, encoding="utf-8") | |
| lock = pathlib.Path("Cargo.lock") | |
| s = lock.read_text(encoding="utf-8") | |
| s, n = re.subn( | |
| r'(\[\[package\]\]\nname = "diffctx"\nversion = ")[^"]+', | |
| lambda m: m.group(1) + ver, | |
| s, | |
| count=1, | |
| ) | |
| assert n == 1, "Cargo.lock: diffctx package entry not found" | |
| lock.write_text(s, encoding="utf-8") | |
| PY | |
| echo "version.py:" | |
| cat src/diffctx/version.py | |
| echo "pyproject.toml (version line):" | |
| grep -E '^version\s*=' pyproject.toml | |
| echo "Cargo.toml (version line):" | |
| grep -E '^version\s*=' crates/diffctx-native/Cargo.toml | |
| - name: Commit version bump (locally only, no push yet) | |
| id: commit_version | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add src/diffctx/version.py pyproject.toml crates/diffctx-native/Cargo.toml Cargo.lock \ | |
| server.json action.yml .claude-plugin/plugin.json docs/product/github-action.md | |
| if ! git diff --staged --quiet; then | |
| git commit -m "Release version ${VERSION}" | |
| else | |
| echo "No changes to commit." | |
| fi | |
| COMMIT_SHA=$(git rev-parse HEAD) | |
| echo "Commit SHA: $COMMIT_SHA" | |
| echo "commit_sha=$COMMIT_SHA" >> "$GITHUB_OUTPUT" | |
| - name: Check tag doesn't already exist | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| TAG="v${VERSION}" | |
| git fetch --tags origin 2>/dev/null || true | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| echo "Error: Tag $TAG already exists locally" | |
| exit 1 | |
| fi | |
| if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then | |
| echo "Error: Tag $TAG already exists on remote" | |
| exit 1 | |
| fi | |
| echo "Tag $TAG does not exist, proceeding..." | |
| - name: Create local tag (no push yet) | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| git tag -a "v${VERSION}" -m "Release version ${VERSION}" | |
| echo "Tag created locally: v${VERSION}" | |
| - name: Create git bundle | |
| run: git bundle create repo.bundle --all | |
| - name: Upload bundle as artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: git-repo-bundle | |
| path: repo.bundle | |
| retention-days: 1 | |
| - name: Set outputs | |
| id: set_outputs | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| echo "version=${VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "tag_name=v${VERSION}" >> "$GITHUB_OUTPUT" | |
| build-wheels: | |
| name: Build wheel (${{ matrix.target }}) | |
| needs: prepare-version | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runner: ubuntu-latest | |
| target: x86_64-unknown-linux-gnu | |
| maturin_target: x86_64 | |
| manylinux: '2_28' | |
| - runner: ubuntu-22.04-arm | |
| target: aarch64-unknown-linux-gnu | |
| maturin_target: aarch64 | |
| manylinux: '2_28' | |
| # NOTE: macos-13 (Intel) runner pool has multi-hour queue times on | |
| # public projects. Apple Silicon (macos-14) wheel covers all modern | |
| # Macs; Intel-Mac users can fall back to the sdist or Rosetta. | |
| - runner: macos-14 | |
| target: aarch64-apple-darwin | |
| maturin_target: aarch64 | |
| manylinux: 'off' | |
| - runner: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| maturin_target: x64 | |
| manylinux: 'off' | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: Download git bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: git-repo-bundle | |
| - name: Restore repository from bundle | |
| shell: bash | |
| env: | |
| TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }} | |
| run: | | |
| git clone repo.bundle repo | |
| cd repo | |
| git checkout "$TAG_NAME" | |
| cat src/diffctx/version.py | |
| grep -E '^version\s*=' pyproject.toml | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Build wheel via maturin | |
| uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 | |
| with: | |
| working-directory: ./repo | |
| target: ${{ matrix.maturin_target }} | |
| manylinux: ${{ matrix.manylinux }} | |
| args: --release --out dist | |
| rust-toolchain: '1.92.0' | |
| - name: List built wheel | |
| shell: bash | |
| run: ls -la ./repo/dist/ | |
| - name: Upload wheel artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: wheel-${{ matrix.target }} | |
| path: ./repo/dist/*.whl | |
| retention-days: 1 | |
| build-sdist: | |
| name: Build sdist | |
| needs: prepare-version | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download git bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: git-repo-bundle | |
| - name: Restore repository from bundle | |
| env: | |
| TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }} | |
| run: | | |
| git clone repo.bundle repo | |
| cd repo | |
| git checkout "$TAG_NAME" | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Build sdist via maturin | |
| uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 | |
| with: | |
| working-directory: ./repo | |
| command: sdist | |
| args: --out dist | |
| - name: Verify sdist contains Rust sources | |
| run: | | |
| tar tzf ./repo/dist/*.tar.gz | grep -E 'crates/diffctx-native/src/.*\.rs$' | head -5 | |
| echo "OK: sdist contains Rust sources" | |
| - name: Upload sdist artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: sdist | |
| path: ./repo/dist/*.tar.gz | |
| retention-days: 1 | |
| build-binaries: | |
| name: Build standalone binary (${{ matrix.target }}) | |
| needs: prepare-version | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runner: ubuntu-latest | |
| target: x86_64-unknown-linux-gnu | |
| archive: tar.gz | |
| - runner: ubuntu-22.04-arm | |
| target: aarch64-unknown-linux-gnu | |
| archive: tar.gz | |
| - runner: macos-14 | |
| target: aarch64-apple-darwin | |
| archive: tar.gz | |
| - runner: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| archive: zip | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: Download git bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: git-repo-bundle | |
| - name: Restore repository from bundle | |
| shell: bash | |
| env: | |
| TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }} | |
| run: | | |
| git clone repo.bundle repo | |
| cd repo | |
| git checkout "$TAG_NAME" | |
| - name: Set up Rust | |
| uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable | |
| with: | |
| toolchain: '1.92.0' | |
| - name: Build release binary | |
| working-directory: ./repo/crates/diffctx-native | |
| run: cargo build --release --locked --bin diffctx | |
| - name: Package binary (tar.gz) | |
| if: matrix.archive == 'tar.gz' | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| mkdir -p out | |
| cp ./repo/target/release/diffctx out/ | |
| cp ./repo/LICENSE ./repo/README.md out/ | |
| tar -czf "diffctx-${VERSION}-${TARGET}.tar.gz" -C out . | |
| ls -la ./*.tar.gz | |
| - name: Package binary (zip) | |
| if: matrix.archive == 'zip' | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| mkdir -p out | |
| cp ./repo/target/release/diffctx.exe out/ | |
| cp ./repo/LICENSE ./repo/README.md out/ | |
| 7z a "diffctx-${VERSION}-${TARGET}.zip" ./out/* | |
| ls -la ./*.zip | |
| - name: Smoke - binary runs | |
| shell: bash | |
| working-directory: ./repo | |
| run: | | |
| BIN=./target/release/diffctx | |
| if [ "${RUNNER_OS}" = "Windows" ]; then BIN="${BIN}.exe"; fi | |
| "$BIN" --version | |
| # Exit 4 is the empty-diff contract, a valid outcome for a release | |
| # commit that carries no semantic context; only the output shape is | |
| # asserted here. | |
| smoke() { | |
| set +e | |
| "$BIN" . --diff HEAD~1..HEAD --no-content "$@" | |
| rc=$? | |
| set -e | |
| [ "$rc" -eq 0 ] || [ "$rc" -eq 4 ] | |
| } | |
| smoke > "${RUNNER_TEMP}/smoke.yaml" | |
| grep -q '^type: diff_context' "${RUNNER_TEMP}/smoke.yaml" | |
| smoke -f json > "${RUNNER_TEMP}/smoke.json" | |
| head -c 1 "${RUNNER_TEMP}/smoke.json" | grep -q '{' | |
| echo "OK: standalone binary works" | |
| - name: Upload binary artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: binary-${{ matrix.target }} | |
| path: | | |
| ./*.tar.gz | |
| ./*.zip | |
| retention-days: 1 | |
| publish-to-pypi: | |
| name: Publish to PyPI | |
| needs: [prepare-version, build-wheels, build-sdist] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: pypi | |
| url: https://pypi.org/p/diffctx | |
| permissions: | |
| id-token: write | |
| steps: | |
| - name: Download all distribution artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./dist-artifacts | |
| pattern: '@(wheel-*|sdist)' | |
| merge-multiple: true | |
| - name: Collect wheels and sdist | |
| run: | | |
| mkdir -p dist | |
| find ./dist-artifacts -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist/ \; | |
| ls -la dist/ | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Validate distributions | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install twine | |
| twine check dist/* | |
| - name: Publish to PyPI (OIDC trusted publishing) | |
| uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 | |
| with: | |
| packages-dir: ./dist | |
| print-hash: true | |
| # Idempotent re-runs: if a wheel for this version was already | |
| # uploaded in a previous (failed) CD run, skip it instead of | |
| # erroring out. New artifacts (e.g. a corrected sdist) still | |
| # publish. | |
| skip-existing: true | |
| # Stated rather than left to the action's default, which has changed | |
| # between versions. PyPI shows a "Verified details" provenance badge | |
| # only when the upload carries attestations (#147). | |
| attestations: true | |
| smoke-pypi: | |
| name: Post-publish smoke test (${{ matrix.os }} / py${{ matrix.python-version }}) | |
| needs: [prepare-version, publish-to-pypi] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| python-version: ['3.10', '3.13'] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Wait for PyPI propagation and install | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| python -m pip install --upgrade pip | |
| # Poll PyPI until the new version is installable. Up to 5 minutes. | |
| for attempt in 1 2 3 4 5 6 7 8 9 10; do | |
| if python -m pip install "diffctx==${VERSION}"; then | |
| echo "Installed diffctx ${VERSION} on attempt $attempt" | |
| break | |
| fi | |
| echo "PyPI not ready yet (attempt $attempt); sleeping 30s" | |
| sleep 30 | |
| done | |
| python -m pip show diffctx | grep -E '^(Name|Version)' | |
| - name: Smoke - diffctx --version | |
| run: diffctx --version | |
| - name: Smoke - tree mapping mode | |
| shell: bash | |
| run: | | |
| mkdir -p smoke-tree && cd smoke-tree | |
| echo "print('hello')" > a.py | |
| echo "x = 1" > b.py | |
| diffctx . --no-content > /dev/null | |
| echo "OK: tree mode works" | |
| - name: Smoke - Rust _diffctx is shipped | |
| shell: bash | |
| run: | | |
| python - <<'PY' | |
| from diffctx._diffctx import GitError, build_diff_context, count_tokens # noqa: F401 | |
| print("OK: _diffctx imported") | |
| PY | |
| - name: Smoke - diff context mode | |
| shell: bash | |
| run: | | |
| mkdir -p smoke-diff && cd smoke-diff | |
| git init -q | |
| git config user.email "smoke@test.local" | |
| git config user.name "Smoke" | |
| mkdir -p pkg | |
| printf 'def add(a, b):\n return a + b\n' > pkg/util.py | |
| printf 'from pkg.util import add\n\ndef main():\n print(add(1, 2))\n' > pkg/main.py | |
| git add -A && git commit -q -m "initial" | |
| printf 'def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n' > pkg/util.py | |
| git add -A && git commit -q -m "add sub" | |
| diffctx . --diff HEAD~1..HEAD --no-content | |
| echo "OK: diff mode works (Rust extension present)" | |
| # server.json, .mcp.json and the Claude plugin all resolve to | |
| # `uvx --from diffctx[mcp] diffctx-mcp`, but nothing here installed the | |
| # [mcp] extra or ran the console script — so renaming the entry point, or | |
| # the extra failing to resolve, shipped green while every MCP consumer | |
| # got a server that would not start. stdout cleanliness is part of the | |
| # contract: stdio multiplexes JSON-RPC over stdout, so one library | |
| # logging there corrupts every frame. | |
| - name: Smoke - diffctx-mcp console script and stdio handshake | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| python -m pip install "diffctx[mcp]==${VERSION}" | |
| command -v diffctx-mcp | |
| python - <<'PY' | |
| import json, os, subprocess, sys | |
| def frame(obj): | |
| return json.dumps(obj) + "\n" | |
| init = frame({ | |
| "jsonrpc": "2.0", "id": 1, "method": "initialize", | |
| "params": { | |
| "protocolVersion": "2024-11-05", | |
| "capabilities": {}, | |
| "clientInfo": {"name": "cd-smoke", "version": "0"}, | |
| }, | |
| }) | |
| initialized = frame({"jsonrpc": "2.0", "method": "notifications/initialized"}) | |
| list_tools = frame({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) | |
| proc = subprocess.run( | |
| ["diffctx-mcp"], | |
| input=init + initialized + list_tools, | |
| capture_output=True, text=True, timeout=120, | |
| ) | |
| lines = [ln for ln in proc.stdout.splitlines() if ln.strip()] | |
| if not lines: | |
| sys.exit(f"no JSON-RPC on stdout; stderr:\n{proc.stderr}") | |
| responses = [] | |
| for ln in lines: | |
| try: | |
| responses.append(json.loads(ln)) | |
| except json.JSONDecodeError: | |
| sys.exit(f"non-JSON-RPC line on stdout: {ln!r}") | |
| def result_for(rpc_id): | |
| for r in responses: | |
| if r.get("id") == rpc_id and "result" in r: | |
| return r["result"] | |
| sys.exit( | |
| f"no result for id={rpc_id} on stdout; responses: {responses!r}\n" | |
| f"stderr:\n{proc.stderr}" | |
| ) | |
| init_result = result_for(1) | |
| reported = init_result["serverInfo"]["version"] | |
| expected = os.environ["VERSION"] | |
| if reported != expected: | |
| sys.exit(f"server reports {reported}, expected {expected}") | |
| tools = result_for(2)["tools"] | |
| names = sorted(t["name"] for t in tools) | |
| # The MCP surface is a single tool since the locate-first flow (#127). | |
| for required in ("diffctx_context",): | |
| if required not in names: | |
| sys.exit(f"tool {required} missing from {names}") | |
| print(f"OK: diffctx-mcp {reported} served {names} over clean stdout") | |
| PY | |
| finalize-release: | |
| name: Push Release and Create GitHub Release | |
| needs: [prepare-version, build-wheels, build-sdist, build-binaries, publish-to-pypi, smoke-pypi] | |
| if: | | |
| always() && | |
| needs.prepare-version.result == 'success' && | |
| needs.build-wheels.result == 'success' && | |
| needs.build-sdist.result == 'success' && | |
| needs.build-binaries.result == 'success' && | |
| (needs.publish-to-pypi.result == 'success' || | |
| needs.publish-to-pypi.result == 'skipped') && | |
| (needs.smoke-pypi.result == 'success' || | |
| needs.smoke-pypi.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| # attest-build-provenance signs via the workflow's OIDC identity and | |
| # records the result in the repository's attestation store. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Download git bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: git-repo-bundle | |
| - name: Restore repository from bundle | |
| env: | |
| # RELEASE_PUSH_TOKEN (not GITHUB_TOKEN): main requires 5 status | |
| # checks on every commit, and a freshly-created local commit has | |
| # none. GITHUB_TOKEN's push is treated as the github-actions[bot] | |
| # identity, which branch protection does NOT exempt; an admin's | |
| # PAT authenticates as an actual repo admin, which the branch | |
| # protection settings (enforce_admins: false) already exempt (#69). | |
| GH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN }} | |
| COMMIT_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| REPO_FULL_NAME: ${{ github.repository }} | |
| run: | | |
| git clone repo.bundle repo | |
| cd repo | |
| git checkout "$COMMIT_SHA" | |
| git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO_FULL_NAME}.git" | |
| - name: Push commit and tag to main | |
| working-directory: ./repo | |
| env: | |
| TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git fetch origin main | |
| REMOTE_MAIN=$(git rev-parse origin/main) | |
| OUR_PARENT=$(git rev-parse HEAD^) | |
| if [ "$REMOTE_MAIN" != "$OUR_PARENT" ]; then | |
| echo "Error: Remote main has changed since release started." | |
| echo "Expected parent: $OUR_PARENT" | |
| echo "Remote main: $REMOTE_MAIN" | |
| exit 1 | |
| fi | |
| git push origin HEAD:main | |
| git push origin "$TAG_NAME" | |
| echo "Successfully pushed version commit and tag" | |
| - name: Download all distribution artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./artifacts | |
| pattern: '@(wheel-*|sdist|binary-*)' | |
| merge-multiple: true | |
| - name: Collect release assets | |
| run: | | |
| mkdir -p release-assets | |
| find ./artifacts -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.zip' \) \ | |
| -exec cp {} release-assets/ \; | |
| ls -la release-assets/ | |
| # An SBOM answers a different question than provenance does: not "who | |
| # built this" but "what is inside it". Both are needed to respond to a | |
| # published CVE without guessing. | |
| - name: Generate SBOM | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| path: ./repo | |
| format: cyclonedx-json | |
| artifact-name: diffctx-sbom.cyclonedx.json | |
| output-file: ./release-assets/diffctx-sbom.cyclonedx.json | |
| # Signed SLSA provenance for every published artifact, verifiable by a | |
| # consumer with `gh attestation verify <file> --repo <owner>/<repo>`. | |
| # This is what lets someone who downloaded a binary from the releases page | |
| # establish that this workflow, on this commit, produced it — the standalone | |
| # binaries in particular have no package index vouching for them the way | |
| # the wheels have PyPI's attestations (#147). | |
| - name: Attest build provenance for release assets | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-path: ./release-assets/* | |
| - name: Create GitHub Release with wheel and sdist assets | |
| uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 | |
| with: | |
| tag_name: ${{ needs.prepare-version.outputs.tag_name }} | |
| name: Release ${{ needs.prepare-version.outputs.version }} | |
| draft: false | |
| prerelease: false | |
| generate_release_notes: true | |
| files: ./release-assets/* | |
| publish-crate: | |
| name: Publish crate to crates.io | |
| needs: [prepare-version, finalize-release] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: write | |
| steps: | |
| # Dispatched, not called as a reusable workflow: the OIDC token of a | |
| # reusable workflow carries the CALLER's filename, which crates.io | |
| # trusted publishing rejects (it is configured for publish-crate.yml). | |
| - name: Dispatch the crate publish workflow | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| gh workflow run publish-crate.yml \ | |
| --repo "$REPO" --ref main \ | |
| -f version="$VERSION" -f ref="$TAG_NAME" | |
| - name: Wait for the crate publish workflow | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| sleep 15 | |
| RUN_ID=$(gh run list --repo "$REPO" --workflow publish-crate.yml \ | |
| --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId') | |
| echo "Watching run $RUN_ID" | |
| gh run watch "$RUN_ID" --repo "$REPO" --exit-status --interval 20 | |
| publish-mcp-registry: | |
| name: Publish to the MCP registry | |
| needs: [prepare-version, finalize-release] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write | |
| contents: read | |
| steps: | |
| # The release tag, not main: finalize-release commits the bumped | |
| # server.json there, and registry versions are immutable — publishing | |
| # main mid-release would race the bump commit. | |
| - name: Checkout release tag | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.prepare-version.outputs.tag_name }} | |
| # Release assets are named linux_amd64/arm64 (Go convention), NOT the | |
| # uname -m x86_64/aarch64 — a naive $(uname -m) URL 404s. Pinned by | |
| # version + sha256; bump both together. | |
| - name: Install mcp-publisher | |
| env: | |
| PUBLISHER_VERSION: v1.8.0 | |
| run: | | |
| PUBLISHER_SHA256=1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf # pragma: allowlist secret | |
| BASE="https://github.com/modelcontextprotocol/registry/releases/download" | |
| curl --proto '=https' --tlsv1.2 -fsSL -o mcp-publisher.tar.gz \ | |
| "${BASE}/${PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" | |
| echo "${PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c - | |
| tar xzf mcp-publisher.tar.gz mcp-publisher | |
| # github-oidc mints the io.github.nikolay-e/* namespace from this | |
| # repository's identity — no stored secret, no device flow. | |
| - name: Publish server.json | |
| run: | | |
| ./mcp-publisher login github-oidc | |
| ./mcp-publisher validate | |
| ./mcp-publisher publish | |
| build-image: | |
| name: Build image (${{ matrix.platform }}) | |
| needs: [prepare-version, finalize-release] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runner: ubuntu-latest | |
| platform: linux/amd64 | |
| slug: amd64 | |
| - runner: ubuntu-22.04-arm | |
| platform: linux/arm64 | |
| slug: arm64 | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| IMAGE: ghcr.io/${{ github.repository_owner }}/diffctx | |
| steps: | |
| - name: Checkout release tag | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.prepare-version.outputs.tag_name }} | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Login to ghcr | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| platforms: ${{ matrix.platform }} | |
| build-args: | | |
| VERSION=${{ needs.prepare-version.outputs.version }} | |
| outputs: >- | |
| type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true | |
| - name: Export digest | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| mkdir -p digests | |
| echo -n "" > "digests/${DIGEST#sha256:}" | |
| - name: Upload digest | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: image-digest-${{ matrix.slug }} | |
| path: digests/* | |
| retention-days: 1 | |
| publish-image: | |
| name: Publish container image manifest | |
| needs: [prepare-version, build-image] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Download digests | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./digests | |
| pattern: image-digest-* | |
| merge-multiple: true | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Login to ghcr | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create and push manifest list | |
| working-directory: ./digests | |
| env: | |
| IMAGE: ghcr.io/${{ github.repository_owner }}/diffctx | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| # shellcheck disable=SC2046 | |
| docker buildx imagetools create \ | |
| -t "${IMAGE}:${VERSION}" \ | |
| -t "${IMAGE}:latest" \ | |
| $(printf "${IMAGE}@sha256:%s " *) | |
| - name: Smoke - image runs | |
| env: | |
| IMAGE: ghcr.io/${{ github.repository_owner }}/diffctx | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| docker run --rm "${IMAGE}:${VERSION}" --version | |
| mkdir -p smoke && cd smoke | |
| git init -q | |
| git config user.email "smoke@test.local" | |
| git config user.name "Smoke" | |
| printf 'def add(a, b):\n return a + b\n' > util.py | |
| git add -A && git commit -q -m "initial" | |
| printf 'def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n' > util.py | |
| git add -A && git commit -q -m "add sub" | |
| docker run --rm -v "$PWD:/repo" "${IMAGE}:${VERSION}" . --diff HEAD~1..HEAD --no-content | |
| update-packaging-manifests: | |
| name: Publish Scoop bucket manifest | |
| needs: [prepare-version, finalize-release] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: ubuntu-latest | |
| # `bucket/` makes this repository itself the Scoop bucket, so pushing the | |
| # refreshed manifest to main IS the publication step — a failure here means | |
| # Windows users stay on the previous version. | |
| steps: | |
| - name: Checkout main | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: main | |
| token: ${{ secrets.RELEASE_PUSH_TOKEN }} | |
| - name: Download binary artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./release-binaries | |
| pattern: binary-* | |
| merge-multiple: true | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Render manifests | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| python scripts/render_packaging.py \ | |
| --version "$VERSION" \ | |
| --assets-dir ./release-binaries \ | |
| --scoop bucket/diffctx.json \ | |
| --npm-checksums packaging/npm/checksums.json | |
| # `bucket/` IS the Scoop bucket, and this manifest was pushed to main with | |
| # nothing checking it — a wrong hash, a stale version, or a `bin` that no | |
| # longer matches the archive layout broke `scoop install` for every | |
| # Windows user silently. Verify against the actual artifact before pushing. | |
| - name: Verify the rendered Scoop manifest | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| python - <<'PY' | |
| import hashlib, json, os, pathlib, sys | |
| version = os.environ["VERSION"] | |
| manifest = json.loads(pathlib.Path("bucket/diffctx.json").read_text()) | |
| if manifest["version"] != version: | |
| sys.exit(f"manifest version {manifest['version']} != {version}") | |
| arch = manifest["architecture"]["64bit"] | |
| url = arch["url"] | |
| if version not in url: | |
| sys.exit(f"asset URL does not carry the version: {url}") | |
| asset_name = url.rsplit("/", 1)[-1] | |
| asset = pathlib.Path("release-binaries") / asset_name | |
| if not asset.is_file(): | |
| have = sorted(p.name for p in pathlib.Path("release-binaries").iterdir()) | |
| sys.exit(f"{asset_name} not among the built artifacts: {have}") | |
| actual = hashlib.sha256(asset.read_bytes()).hexdigest() | |
| if actual.lower() != arch["hash"].lower(): | |
| sys.exit(f"hash mismatch for {asset_name}: manifest {arch['hash']}, actual {actual}") | |
| declared_bin = manifest["bin"] | |
| bins = declared_bin if isinstance(declared_bin, list) else [declared_bin] | |
| import zipfile | |
| with zipfile.ZipFile(asset) as zf: | |
| members = zf.namelist() | |
| for entry in bins: | |
| target = entry[0] if isinstance(entry, list) else entry | |
| if target not in members: | |
| sys.exit(f"manifest bin {target!r} is not in the archive: {members}") | |
| print(f"OK: {asset_name} hash matches and bin {bins} resolves inside the archive") | |
| PY | |
| - name: Commit and push | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add bucket/diffctx.json packaging/npm/checksums.json | |
| if git diff --staged --quiet; then | |
| echo "Manifests already current." | |
| exit 0 | |
| fi | |
| git commit -m "chore: packaging manifests for ${VERSION}" | |
| git pull --rebase origin main | |
| git push origin HEAD:main | |
| publish-extras: | |
| name: Publish npm wrapper and mirror to Docker Hub | |
| # No edge to update-packaging-manifests: publish-extras.yml re-renders the | |
| # npm checksums from the release tag, so a Scoop bucket failure can never | |
| # hold back npm or the Docker Hub mirror. | |
| needs: [prepare-version, publish-image] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: write | |
| steps: | |
| # Dispatched rather than inlined so the downstream channels stay | |
| # re-runnable on their own after a partial release. Left un-dispatched, | |
| # npm and Docker Hub silently keep serving the previous version — that is | |
| # how 1.12.1 stayed live on both while every other channel moved on. | |
| - name: Dispatch the downstream publish workflow | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| gh workflow run publish-extras.yml \ | |
| --repo "$REPO" --ref main \ | |
| -f version="$VERSION" \ | |
| -f publish_npm=true \ | |
| -f mirror_dockerhub=true | |
| - name: Wait for the downstream publish workflow | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| sleep 15 | |
| RUN_ID=$(gh run list --repo "$REPO" --workflow publish-extras.yml \ | |
| --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId') | |
| echo "Watching run $RUN_ID" | |
| gh run watch "$RUN_ID" --repo "$REPO" --exit-status --interval 20 |