Repository navigation
diffctx CD #92
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' | |
| build_only: | |
| description: 'Build and smoke every release artifact from this ref; publish nothing' | |
| required: false | |
| default: false | |
| type: boolean | |
| 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 }} | |
| BUILD_ONLY: ${{ inputs.build_only }} | |
| PUBLISH: ${{ github.event.inputs.publish_to_pypi }} | |
| run: | | |
| if [ "$BUILD_ONLY" = "true" ]; then | |
| if [ "$PUBLISH" = "true" ]; then | |
| echo "Error: build_only cannot publish; set publish_to_pypi to false." | |
| exit 1 | |
| fi | |
| echo "Build-only run on $CURRENT_BRANCH: nothing will be pushed or published." | |
| exit 0 | |
| fi | |
| 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("CITATION.cff") | |
| s = p.read_text(encoding="utf-8") | |
| s, n = re.subn(r'^version:\s*.*$', f'version: {ver}', s, count=1, flags=re.M) | |
| assert n == 1, "CITATION.cff has no version line" | |
| p.write_text(s, encoding="utf-8") | |
| 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("plugin/.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") | |
| mcp = pathlib.Path("plugin/.mcp.json") | |
| doc = json.loads(mcp.read_text(encoding="utf-8")) | |
| args = doc["mcpServers"]["diffctx"]["args"] | |
| [i] = [i for i, a in enumerate(args) if a.startswith("diffctx[mcp]==")] | |
| args[i] = f"diffctx[mcp]=={ver}" | |
| mcp.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") | |
| # CI installs with `uv sync --locked`, which refuses a lockfile whose | |
| # editable diffctx entry lags pyproject — every job after 1.17.0 did. | |
| uv_lock = pathlib.Path("uv.lock") | |
| s = uv_lock.read_text(encoding="utf-8") | |
| s, n = re.subn( | |
| r'(\[\[package\]\]\nname = "diffctx"\nversion = ")[^"]+(?="\nsource = \{ editable = "\." \})', | |
| lambda m: m.group(1) + ver, | |
| s, | |
| count=1, | |
| ) | |
| assert n == 1, "uv.lock: editable diffctx entry not found" | |
| uv_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 | |
| # tests/test_version_consistency.py requires a `## [<version>] - <date>` | |
| # section; 1.16.0 needed a hand commit for it. Rolling [Unreleased] here | |
| # is what keeps that gate from ever blocking the release it guards. | |
| - name: Roll the CHANGELOG [Unreleased] section into the release | |
| env: | |
| VERSION: ${{ github.event.inputs.version }} | |
| run: | | |
| python - <<'PY' | |
| import datetime, os, pathlib, re | |
| ver = os.environ["VERSION"] | |
| p = pathlib.Path("CHANGELOG.md") | |
| s = p.read_text(encoding="utf-8") | |
| if re.search(rf"(?m)^## \[{re.escape(ver)}\] - \d{{4}}-\d{{2}}-\d{{2}}$", s): | |
| print(f"CHANGELOG.md already has a {ver} section") | |
| raise SystemExit(0) | |
| today = datetime.date.today().isoformat() | |
| s, n = re.subn(r"(?m)^## \[Unreleased\]\n", f"## [Unreleased]\n\n## [{ver}] - {today}\n", s, count=1) | |
| assert n == 1, "CHANGELOG.md: no ## [Unreleased] heading" | |
| s, n = re.subn( | |
| r"(?m)^\[Unreleased\]: (\S+)/compare/v([^.\s]+(?:\.[^.\s]+)*)\.\.\.HEAD$", | |
| lambda m: ( | |
| f"[Unreleased]: {m.group(1)}/compare/v{ver}...HEAD\n" | |
| f"[{ver}]: {m.group(1)}/compare/v{m.group(2)}...v{ver}" | |
| ), | |
| s, | |
| count=1, | |
| ) | |
| assert n == 1, "CHANGELOG.md: no [Unreleased] compare link" | |
| p.write_text(s, encoding="utf-8") | |
| print(f"CHANGELOG.md: [Unreleased] rolled into [{ver}] - {today}") | |
| PY | |
| grep -n -m3 -E '^## \[' CHANGELOG.md | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| # The plugin's uvx launcher pins diffctx; this pins what diffctx pulls | |
| # in, to the set CI installed from uv.lock, so an installed plugin runs | |
| # the dependency versions this release was tested with. | |
| - name: Pin the plugin's dependency set from uv.lock | |
| run: | | |
| uv export --frozen --no-dev --extra mcp --no-emit-project --no-hashes \ | |
| --no-header --no-annotate -q > plugin/constraints.txt | |
| wc -l plugin/constraints.txt | |
| - 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 uv.lock \ | |
| server.json action.yml plugin/.claude-plugin/plugin.json plugin/.mcp.json plugin/constraints.txt \ | |
| docs/product/github-action.md CITATION.cff \ | |
| CHANGELOG.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' | |
| - runner: macos-14 | |
| target: aarch64-apple-darwin | |
| maturin_target: aarch64 | |
| manylinux: 'off' | |
| # Cross-compiled on Apple Silicon: the Intel runner pool queues for | |
| # hours on public projects, and Xcode's SDK targets both arches. | |
| # The x64 interpreter runs under Rosetta, so the wheel is imported | |
| # by the same kind of process an Intel-Mac user has — the arm64 | |
| # interpreter cannot import it, and until this leg the Intel wheel | |
| # shipped without ever being imported anywhere. | |
| - runner: macos-14 | |
| target: x86_64-apple-darwin | |
| maturin_target: x86_64 | |
| manylinux: 'off' | |
| python_arch: x64 | |
| - runner: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| maturin_target: x64 | |
| manylinux: 'off' | |
| - runner: windows-11-arm | |
| target: aarch64-pc-windows-msvc | |
| maturin_target: aarch64 | |
| 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 | |
| # 3.14t: the abi3 wheel is refused by the free-threaded ABI, so without a | |
| # cp314t wheel pip and uvx compiled the sdist there (and timed the MCP | |
| # client out). PyO3 0.29 builds it and the module keeps the GIL off. | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: | | |
| 3.14t | |
| 3.12 | |
| architecture: ${{ matrix.python_arch }} | |
| # DIFFCTX_BUILD_SHA is read by option_env! at compile time and lands in | |
| # `provenance.engine.build`; the manylinux build runs in a container, so | |
| # the variable is forwarded explicitly there. | |
| - name: Build wheel via maturin | |
| uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 | |
| env: | |
| DIFFCTX_BUILD_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| with: | |
| working-directory: ./repo | |
| target: ${{ matrix.maturin_target }} | |
| manylinux: ${{ matrix.manylinux }} | |
| # Inside the manylinux container every interpreter is python3.X; | |
| # on a host setup-python's default (3.12, listed last) is `python`. | |
| args: >- | |
| --release --out dist -i python3.14t | |
| -i ${{ matrix.manylinux != 'off' && 'python3.12' || 'python' }} | |
| rust-toolchain: '1.92.0' | |
| docker-options: -e DIFFCTX_BUILD_SHA | |
| - name: List built wheel | |
| shell: bash | |
| run: ls -la ./repo/dist/ | |
| - name: Smoke - both wheels install and run on their platform | |
| shell: bash | |
| working-directory: ./repo | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| BUILD_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| run: | | |
| run=() | |
| if [ "$TARGET" = "x86_64-apple-darwin" ]; then run=(arch -x86_64); fi | |
| version=$(cut -d'"' -f2 src/diffctx/version.py) | |
| for pair in "python:abi3" "python3.14t:cp314t"; do | |
| python="${pair%%:*}" tag="${pair##*:}" | |
| wheel=$(ls dist/*-"${tag}"-*.whl) | |
| venv="${RUNNER_TEMP}/smoke-${tag}" | |
| "${run[@]}" "$python" -m venv "$venv" | |
| bin="$venv/bin" | |
| if [ -d "$venv/Scripts" ]; then bin="$venv/Scripts"; fi | |
| if [ "$TARGET" = "x86_64-apple-darwin" ]; then | |
| "${run[@]}" "$bin/python" -c 'import platform; print(platform.machine())' | grep -qx x86_64 | |
| fi | |
| "${run[@]}" "$bin/python" -m pip install --quiet --disable-pip-version-check "$wheel" | |
| "${run[@]}" "$bin/diffctx" --version | grep -qx "diffctx ${version}" | |
| if [ "$tag" = cp314t ]; then | |
| "${run[@]}" "$bin/python" -c 'import sys, diffctx._diffctx; assert not sys._is_gil_enabled()' | |
| fi | |
| set +e | |
| "${run[@]}" "$bin/diffctx" . --diff HEAD~1..HEAD -f json -o "${RUNNER_TEMP}/smoke-${tag}.json" | |
| rc=$? | |
| set -e | |
| [ "$rc" -eq 0 ] || [ "$rc" -eq 4 ] | |
| jq -e --arg sha "$BUILD_SHA" '.provenance.engine.build == $sha' "${RUNNER_TEMP}/smoke-${tag}.json" | |
| "${run[@]}" "$bin/diffctx" src -f yaml > "${RUNNER_TEMP}/tree-${tag}.yaml" | |
| grep -q 'version.py' "${RUNNER_TEMP}/tree-${tag}.yaml" | |
| echo "OK: $wheel works" | |
| done | |
| - 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: Set up Rust | |
| uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable | |
| with: | |
| toolchain: '1.92.0' | |
| - 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 | |
| # `shell: bash` sets pipefail so the verdict is grep's alone; the | |
| # listing is for the log. It used to go through `| head -5`, which | |
| # closed the pipe after five lines and made grep fail with a write | |
| # error under pipefail — the 1.16.0 release died on its own log line. | |
| shell: bash | |
| run: | | |
| listing=$(tar tzf ./repo/dist/*.tar.gz) | |
| echo "${listing}" | grep -m5 -E 'crates/diffctx-native/src/.*\.rs$' | |
| echo "OK: sdist contains Rust sources" | |
| # The sdist is what every platform without a wheel falls back to, and | |
| # listing its members proves nothing about whether it builds: a missing | |
| # rust-toolchain.toml or Cargo.lock passes the grep above and fails only | |
| # on a user's machine. | |
| - name: Smoke - the sdist builds and runs from a fresh venv | |
| shell: bash | |
| run: | | |
| python -m venv "${RUNNER_TEMP}/sdist-smoke" | |
| bin="${RUNNER_TEMP}/sdist-smoke/bin" | |
| "$bin/python" -m pip install --quiet --disable-pip-version-check ./repo/dist/*.tar.gz | |
| "$bin/diffctx" --version | grep -qx "diffctx $(cut -d'"' -f2 ./repo/src/diffctx/version.py)" | |
| SMOKE=$(mktemp -d) | |
| 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" | |
| "$bin/diffctx" . --diff HEAD~1..HEAD -f md -o "${RUNNER_TEMP}/sdist-smoke.md" | |
| grep -q "def sub" "${RUNNER_TEMP}/sdist-smoke.md" | |
| echo "OK: sdist installs and runs" | |
| - 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: macos-14 | |
| target: x86_64-apple-darwin | |
| archive: tar.gz | |
| - runner: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| archive: zip | |
| - runner: windows-11-arm | |
| target: aarch64-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@6bed0761d98439e5a578e2877258200ad565ba87 # stable | |
| with: | |
| toolchain: '1.92.0' | |
| targets: ${{ matrix.target }} | |
| - name: Build release binary | |
| working-directory: ./repo/crates/diffctx-native | |
| env: | |
| DIFFCTX_BUILD_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| run: cargo build --release --locked --bin diffctx --target ${{ matrix.target }} | |
| - 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/${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/${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 | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| BUILD_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| # The x86_64 macOS binary runs here under Rosetta, which the arm64 | |
| # runner image ships. | |
| run: | | |
| BIN="./target/${TARGET}/release/diffctx" | |
| if [ "${RUNNER_OS}" = "Windows" ]; then BIN="${BIN}.exe"; fi | |
| "$BIN" --version | grep -qx "diffctx ${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 '{' | |
| jq -e --arg sha "$BUILD_SHA" '.provenance.engine.build == $sha' "${RUNNER_TEMP}/smoke.json" | |
| echo "OK: standalone binary works" | |
| # The shape smoke above proves the binary starts; this runs the whole | |
| # native_cli suite (exit codes, pipes, logging, budget, locate, secrets) | |
| # against the artifact itself, on the platform it ships for — the cross | |
| # builds (Intel Mac under Rosetta, Windows and Linux on ARM) are tested | |
| # nowhere else. | |
| - name: E2E - the shipped binary passes native_cli | |
| shell: bash | |
| working-directory: ./repo/crates/diffctx-native | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| BIN="$(cd ../.. && pwd)/target/${TARGET}/release/diffctx" | |
| if [ "${RUNNER_OS}" = "Windows" ]; then BIN="${BIN}.exe"; fi | |
| git config --global user.email "e2e@test.local" | |
| git config --global user.name "e2e" | |
| DIFFCTX_NATIVE_BIN="$BIN" cargo test --release --locked --test native_cli | |
| - 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 wheels and sdist | |
| 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 --only-binary :all: --upgrade pip | |
| python -m pip install --only-binary :all: 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.14'] | |
| # Windows on ARM has native CPython only from 3.11. macos-15-intel is | |
| # the only Intel-Mac pool GitHub still hosts (macos-13 retired). | |
| include: | |
| - os: windows-11-arm | |
| python-version: '3.13' | |
| - os: ubuntu-22.04-arm | |
| python-version: '3.13' | |
| - os: macos-15-intel | |
| python-version: '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: Set up uv | |
| uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| - name: Wait for PyPI propagation and install | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| python -m pip install --only-binary :all: --upgrade pip | |
| # Poll PyPI until the new version is installable. Up to 5 minutes. | |
| # Wheels only: the smoke tests the published wheel, and an sdist | |
| # fallback would build from source and run its setup scripts. | |
| for attempt in 1 2 3 4 5 6 7 8 9 10; do | |
| if python -m pip install --only-binary :all: "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 | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: diffctx --version | grep -qx "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)" | |
| # README's first install line. `uvx` resolves its own interpreter, so a | |
| # published wheel set that pip could install may still leave this | |
| # command building the sdist. | |
| - name: Smoke - uvx diffctx (README's zero-install line) | |
| shell: bash | |
| working-directory: smoke-diff | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| uvx --python 3.13 --no-build "diffctx==${VERSION}" . --diff HEAD~1..HEAD -f md -o "${RUNNER_TEMP}/uvx.md" | |
| grep -q "def sub" "${RUNNER_TEMP}/uvx.md" | |
| echo "OK: uvx runs the published wheel" | |
| # 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 --only-binary :all: "diffctx[mcp]==${VERSION}" | |
| command -v diffctx-mcp | |
| python - <<'PY' | |
| import json, os, subprocess, sys, tempfile, threading | |
| 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"}) | |
| # The server may exit on stdin EOF before answering a request still | |
| # in flight, so stdin stays open until every awaited id has a reply. | |
| stderr_file = tempfile.TemporaryFile(mode="w+", encoding="utf-8") | |
| proc = subprocess.Popen( | |
| ["diffctx-mcp"], | |
| stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr_file, | |
| text=True, encoding="utf-8", | |
| ) | |
| watchdog = threading.Timer(120, proc.kill) | |
| watchdog.daemon = True | |
| watchdog.start() | |
| responses = [] | |
| def await_id(rpc_id): | |
| while not any(r.get("id") == rpc_id for r in responses): | |
| ln = proc.stdout.readline() | |
| if not ln: | |
| return | |
| if not ln.strip(): | |
| continue | |
| try: | |
| responses.append(json.loads(ln)) | |
| except json.JSONDecodeError: | |
| sys.exit(f"non-JSON-RPC line on stdout: {ln!r}") | |
| def send(payload): | |
| try: | |
| proc.stdin.write(payload) | |
| proc.stdin.flush() | |
| except BrokenPipeError: | |
| pass | |
| send(init) | |
| await_id(1) | |
| send(initialized + list_tools) | |
| await_id(2) | |
| try: | |
| proc.stdin.close() | |
| except BrokenPipeError: | |
| pass | |
| for ln in proc.stdout: | |
| if ln.strip(): | |
| try: | |
| responses.append(json.loads(ln)) | |
| except json.JSONDecodeError: | |
| sys.exit(f"non-JSON-RPC line on stdout: {ln!r}") | |
| proc.wait() | |
| watchdog.cancel() | |
| stderr_file.seek(0) | |
| stderr = stderr_file.read() | |
| 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{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 | |
| # uvx on free-threaded 3.14t used to find no wheel (abi3 is refused by that | |
| # ABI), compile the sdist and time the MCP client out. build-wheels now | |
| # ships cp314t; --no-build turns any regression back to the sdist into a | |
| # fast, loud failure here instead of a slow one on a user's machine. | |
| smoke-pypi-free-threaded: | |
| name: Post-publish smoke test (free-threaded 3.14t wheel) | |
| 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] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| - name: Install from the cp314t wheel, never the sdist | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| for attempt in 1 2 3 4 5 6 7 8 9 10; do | |
| if out=$(uvx --refresh --python 3.14t --no-build "diffctx==${VERSION}" --version 2>&1); then break; fi | |
| echo "$out" | |
| echo "not installable yet (attempt $attempt); sleeping 30s" | |
| sleep 30 | |
| done | |
| echo "$out" | |
| printf '%s\n' "$out" | tail -n 1 | tr -d '\r' | grep -qx "diffctx ${VERSION}" | |
| - name: Smoke - diff mode with the GIL off | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| SMOKE=$(mktemp -d) | |
| 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" | |
| uvx --python 3.14t --no-build --from "diffctx==${VERSION}" python -c \ | |
| 'import sys, diffctx._diffctx; assert not sys._is_gil_enabled(), "GIL re-enabled"' | |
| uvx --python 3.14t --no-build "diffctx==${VERSION}" . --diff HEAD~1..HEAD -f md -o out.md | |
| grep -q "def sub" out.md | |
| echo "OK: cp314t wheel runs diff mode" | |
| finalize-release: | |
| name: Push Release and Create GitHub Release | |
| needs: | |
| - prepare-version | |
| - build-wheels | |
| - build-sdist | |
| - build-binaries | |
| - publish-to-pypi | |
| - smoke-pypi | |
| - smoke-pypi-free-threaded | |
| if: | | |
| always() && | |
| !inputs.build_only && | |
| 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') && | |
| (needs.smoke-pypi-free-threaded.result == 'success' || | |
| needs.smoke-pypi-free-threaded.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 standalone binary artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./artifacts | |
| pattern: 'binary-*' | |
| merge-multiple: true | |
| # Wheels and the sdist are NOT attached when PyPI published them: PyPI | |
| # vouches for them with its own attestations, and a second copy here is | |
| # duplication every release cleanup had to undo by hand. A run that | |
| # skipped PyPI has nowhere else to put them, so on that path they ride | |
| # with the release. | |
| - name: Download wheels and sdist (PyPI skipped) | |
| if: github.event.inputs.publish_to_pypi != 'true' | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ./artifacts | |
| pattern: '@(wheel-*|sdist)' | |
| 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@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 | |
| 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 the standalone binaries | |
| uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 | |
| 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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - name: Login to ghcr | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Key the OS upgrade layer to today | |
| id: apt | |
| run: echo "refresh=$(date -u +%F)" >> "$GITHUB_OUTPUT" | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| platforms: ${{ matrix.platform }} | |
| build-args: | | |
| VERSION=${{ needs.prepare-version.outputs.version }} | |
| DIFFCTX_BUILD_SHA=${{ needs.prepare-version.outputs.commit_sha }} | |
| APT_REFRESH=${{ steps.apt.outputs.refresh }} | |
| 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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - 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 }} | |
| BUILD_SHA: ${{ needs.prepare-version.outputs.commit_sha }} | |
| run: | | |
| docker run --rm "${IMAGE}:${VERSION}" --version | grep -qx "diffctx ${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" | |
| chmod -R a+rX . | |
| docker run --rm -v "$PWD:/repo" "${IMAGE}:${VERSION}" . --diff HEAD~1..HEAD -f json > out.json | |
| grep -q "def sub" out.json | |
| jq -e --arg sha "$BUILD_SHA" '.provenance.engine.build == $sha' out.json | |
| 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 \ | |
| --plugin-checksums plugin/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}") | |
| if set(manifest["architecture"]) != {"64bit", "arm64"}: | |
| sys.exit(f"manifest architectures {sorted(manifest['architecture'])} != ['64bit', 'arm64']") | |
| declared_bin = manifest["bin"] | |
| bins = declared_bin if isinstance(declared_bin, list) else [declared_bin] | |
| import zipfile | |
| for name, arch in manifest["architecture"].items(): | |
| url = arch["url"] | |
| if version not in url: | |
| sys.exit(f"{name}: 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}") | |
| 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 {name} 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 plugin/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 | |
| # README advertises `scoop install diffctx/diffctx`; the job above verified | |
| # the manifest by hash and pushed it, and no Windows machine ever ran the | |
| # install. This one does, from the just-pushed bucket, against the public | |
| # release URL — a drafted release or a wrong `bin` fails here. | |
| smoke-scoop: | |
| name: Smoke - scoop install from the pushed bucket | |
| needs: [prepare-version, update-packaging-manifests] | |
| if: github.event.inputs.publish_to_pypi == 'true' | |
| runs-on: windows-latest | |
| steps: | |
| - name: Install Scoop | |
| shell: pwsh | |
| run: | | |
| Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force | |
| Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin" | |
| "$env:USERPROFILE\scoop\shims" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 | |
| - name: Install diffctx from the bucket | |
| shell: pwsh | |
| env: | |
| REPO_URL: ${{ github.server_url }}/${{ github.repository }} | |
| run: | | |
| scoop bucket add diffctx $env:REPO_URL | |
| scoop install diffctx/diffctx | |
| - name: Assert the installed version | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare-version.outputs.version }} | |
| run: | | |
| diffctx --version | grep -qx "diffctx ${VERSION}" | |
| echo "OK: scoop installed diffctx ${VERSION}" | |
| 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 |