Skip to content

diffctx CD

diffctx CD #92

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'
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