Skip to content

diffctx CD

diffctx CD #81

Workflow file for this run

# .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