pylibgrit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. pylibgrit-0.1.0/.github/scripts/check_release_inventory.py +94 -0
  2. pylibgrit-0.1.0/.github/scripts/test_check_release_inventory.py +84 -0
  3. pylibgrit-0.1.0/.github/workflows/ci.yml +193 -0
  4. pylibgrit-0.1.0/.github/workflows/release.yml +289 -0
  5. pylibgrit-0.1.0/.gitignore +11 -0
  6. pylibgrit-0.1.0/Cargo.lock +1320 -0
  7. pylibgrit-0.1.0/Cargo.toml +21 -0
  8. pylibgrit-0.1.0/LICENSE +21 -0
  9. pylibgrit-0.1.0/PKG-INFO +284 -0
  10. pylibgrit-0.1.0/README.md +266 -0
  11. pylibgrit-0.1.0/docs/superpowers/api-matrix.md +334 -0
  12. pylibgrit-0.1.0/docs/superpowers/plans/2026-06-13-pygrit-bindings.md +2023 -0
  13. pylibgrit-0.1.0/docs/superpowers/plans/2026-06-14-pygrit-6wheel-coverage.md +564 -0
  14. pylibgrit-0.1.0/docs/superpowers/plans/2026-06-14-pygrit-release-readiness.md +813 -0
  15. pylibgrit-0.1.0/docs/superpowers/specs/2026-06-13-pygrit-bindings-design.md +299 -0
  16. pylibgrit-0.1.0/docs/superpowers/specs/2026-06-14-pygrit-6wheel-coverage-design.md +173 -0
  17. pylibgrit-0.1.0/docs/superpowers/specs/2026-06-14-pygrit-release-readiness-design.md +277 -0
  18. pylibgrit-0.1.0/docs/superpowers/specs/2026-06-15-grit-lib-write-surface-spike.md +112 -0
  19. pylibgrit-0.1.0/pyproject.toml +43 -0
  20. pylibgrit-0.1.0/python/pylibgrit/__init__.py +64 -0
  21. pylibgrit-0.1.0/python/pylibgrit/__init__.pyi +242 -0
  22. pylibgrit-0.1.0/python/pylibgrit/py.typed +0 -0
  23. pylibgrit-0.1.0/rust-toolchain.toml +3 -0
  24. pylibgrit-0.1.0/src/config.rs +57 -0
  25. pylibgrit-0.1.0/src/diff.rs +246 -0
  26. pylibgrit-0.1.0/src/error.rs +107 -0
  27. pylibgrit-0.1.0/src/lib.rs +49 -0
  28. pylibgrit-0.1.0/src/objects.rs +567 -0
  29. pylibgrit-0.1.0/src/odb.rs +44 -0
  30. pylibgrit-0.1.0/src/refs.rs +141 -0
  31. pylibgrit-0.1.0/src/repository.rs +543 -0
  32. pylibgrit-0.1.0/src/revwalk.rs +62 -0
  33. pylibgrit-0.1.0/tests/__init__.py +0 -0
  34. pylibgrit-0.1.0/tests/conftest.py +56 -0
  35. pylibgrit-0.1.0/tests/gitlib.py +54 -0
  36. pylibgrit-0.1.0/tests/test_concurrency.py +38 -0
  37. pylibgrit-0.1.0/tests/test_config.py +32 -0
  38. pylibgrit-0.1.0/tests/test_diff.py +305 -0
  39. pylibgrit-0.1.0/tests/test_errors.py +8 -0
  40. pylibgrit-0.1.0/tests/test_ffi_lifetime.py +81 -0
  41. pylibgrit-0.1.0/tests/test_objectid.py +35 -0
  42. pylibgrit-0.1.0/tests/test_objectkind.py +21 -0
  43. pylibgrit-0.1.0/tests/test_objects.py +446 -0
  44. pylibgrit-0.1.0/tests/test_odb.py +41 -0
  45. pylibgrit-0.1.0/tests/test_refs.py +53 -0
  46. pylibgrit-0.1.0/tests/test_repository.py +76 -0
  47. pylibgrit-0.1.0/tests/test_resolve.py +52 -0
  48. pylibgrit-0.1.0/tests/test_revwalk.py +169 -0
  49. pylibgrit-0.1.0/tests/test_smoke.py +6 -0
  50. pylibgrit-0.1.0/uv.lock +327 -0
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the release artifact set before publishing to PyPI.
3
+
4
+ Asserts that a ``dist/`` directory contains exactly the artifacts the release
5
+ workflow is expected to produce: five CPython ``abi3`` wheels (one per target
6
+ platform) and one sdist, every file carrying the same project version. Any
7
+ deviation -- a missing or extra artifact, a version disagreement, a non-abi3
8
+ wheel -- is a hard error, so a malformed upload set can never reach PyPI.
9
+
10
+ Because each target platform yields a uniquely named wheel, "exactly 5 wheels"
11
+ already implies five distinct platforms; there is no separate platform-tag
12
+ check (the exact tag strings shift with runner images and would be brittle).
13
+
14
+ Usage: ``python check_release_inventory.py <dist-dir>``
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ DIST_NAME = "pylibgrit"
23
+ # AIDEV-NOTE: EXPECTED_WHEELS must equal the number of build legs in the release
24
+ # workflow's `build` matrix (.github/workflows/release.yml). Bump both together when
25
+ # adding or removing a wheel target.
26
+ EXPECTED_WHEELS = 5
27
+ SDIST_SUFFIX = ".tar.gz"
28
+ WHEEL_SUFFIX = ".whl"
29
+
30
+
31
+ def check_inventory(dist_dir: Path) -> list[str]:
32
+ """Return a list of human-readable problems with ``dist_dir`` (empty == OK)."""
33
+ errors: list[str] = []
34
+ versions: set[str] = set()
35
+
36
+ sdists = sorted(dist_dir.glob(f"*{SDIST_SUFFIX}"))
37
+ if len(sdists) != 1:
38
+ names = [p.name for p in sdists]
39
+ errors.append(
40
+ f"expected exactly 1 sdist (*{SDIST_SUFFIX}), found {len(sdists)}: {names}"
41
+ )
42
+ for sdist in sdists:
43
+ stem = sdist.name[: -len(SDIST_SUFFIX)]
44
+ parts = stem.split("-")
45
+ if len(parts) != 2 or parts[0] != DIST_NAME:
46
+ errors.append(f"unexpected sdist filename: {sdist.name}")
47
+ continue
48
+ versions.add(parts[1])
49
+
50
+ wheels = sorted(dist_dir.glob(f"*{WHEEL_SUFFIX}"))
51
+ if len(wheels) != EXPECTED_WHEELS:
52
+ names = [p.name for p in wheels]
53
+ errors.append(
54
+ f"expected exactly {EXPECTED_WHEELS} wheels, found {len(wheels)}: {names}"
55
+ )
56
+ for wheel in wheels:
57
+ stem = wheel.name[: -len(WHEEL_SUFFIX)]
58
+ parts = stem.split("-")
59
+ # Expected: name-version-pythontag-abitag-platformtag (no build tag).
60
+ if len(parts) != 5 or parts[0] != DIST_NAME:
61
+ errors.append(f"unexpected wheel filename: {wheel.name}")
62
+ continue
63
+ _, version, python_tag, abi_tag, _platform = parts
64
+ versions.add(version)
65
+ # AIDEV-NOTE: cp311 is the project's minimum Python (abi3 floor); update this
66
+ # tag here when bumping requires-python in pyproject.toml / Cargo.toml abi3-py*.
67
+ if python_tag != "cp311" or abi_tag != "abi3":
68
+ errors.append(f"wheel is not cp311-abi3: {wheel.name}")
69
+
70
+ if len(versions) > 1:
71
+ errors.append(f"artifacts disagree on version: {sorted(versions)}")
72
+
73
+ return errors
74
+
75
+
76
+ def main(argv: list[str]) -> int:
77
+ if len(argv) != 2:
78
+ print(f"usage: {argv[0]} <dist-dir>", file=sys.stderr)
79
+ return 2
80
+ dist_dir = Path(argv[1])
81
+ if not dist_dir.is_dir():
82
+ print(f"not a directory: {dist_dir}", file=sys.stderr)
83
+ return 2
84
+ errors = check_inventory(dist_dir)
85
+ if errors:
86
+ for err in errors:
87
+ print(f"::error::release inventory: {err}", file=sys.stderr)
88
+ return 1
89
+ print(f"release inventory OK: {EXPECTED_WHEELS} wheels + 1 sdist, single version")
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main(sys.argv))
@@ -0,0 +1,84 @@
1
+ """Tests for the release-inventory checker.
2
+
3
+ Release tooling — intentionally outside ``tests/`` so the binding suite (run by
4
+ CI's ``pytest tests/``) does not collect it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).parent))
13
+
14
+ from check_release_inventory import check_inventory # noqa: E402
15
+
16
+
17
+ def _touch(directory: Path, name: str) -> None:
18
+ (directory / name).write_bytes(b"")
19
+
20
+
21
+ def _good_dist(directory: Path, version: str = "0.1.0") -> None:
22
+ _touch(directory, f"pylibgrit-{version}.tar.gz")
23
+ _touch(
24
+ directory,
25
+ f"pylibgrit-{version}-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
26
+ )
27
+ _touch(
28
+ directory,
29
+ f"pylibgrit-{version}-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
30
+ )
31
+ _touch(directory, f"pylibgrit-{version}-cp311-abi3-musllinux_1_2_x86_64.whl")
32
+ _touch(directory, f"pylibgrit-{version}-cp311-abi3-musllinux_1_2_aarch64.whl")
33
+ _touch(directory, f"pylibgrit-{version}-cp311-abi3-macosx_11_0_arm64.whl")
34
+
35
+
36
+ def test_valid_inventory_passes(tmp_path: Path) -> None:
37
+ _good_dist(tmp_path)
38
+ assert check_inventory(tmp_path) == []
39
+
40
+
41
+ def test_missing_sdist_fails(tmp_path: Path) -> None:
42
+ _good_dist(tmp_path)
43
+ (tmp_path / "pylibgrit-0.1.0.tar.gz").unlink()
44
+ errors = check_inventory(tmp_path)
45
+ assert any("sdist" in e for e in errors), errors
46
+
47
+
48
+ def test_extra_sdist_fails(tmp_path: Path) -> None:
49
+ _good_dist(tmp_path)
50
+ _touch(tmp_path, "pylibgrit-0.1.0.zip.tar.gz")
51
+ errors = check_inventory(tmp_path)
52
+ assert any("sdist" in e for e in errors), errors
53
+
54
+
55
+ def test_too_few_wheels_fails(tmp_path: Path) -> None:
56
+ _good_dist(tmp_path)
57
+ (tmp_path / "pylibgrit-0.1.0-cp311-abi3-macosx_11_0_arm64.whl").unlink()
58
+ errors = check_inventory(tmp_path)
59
+ assert any("5 wheels" in e for e in errors), errors
60
+
61
+
62
+ def test_too_many_wheels_fails(tmp_path: Path) -> None:
63
+ _good_dist(tmp_path)
64
+ # A 6th valid abi3 wheel (distinct platform, same version) — only the count check
65
+ # should fire, proving the gate rejects over-count as well as under-count.
66
+ _touch(tmp_path, "pylibgrit-0.1.0-cp311-abi3-macosx_14_0_arm64.whl")
67
+ errors = check_inventory(tmp_path)
68
+ assert any("5 wheels" in e for e in errors), errors
69
+
70
+
71
+ def test_non_abi3_wheel_fails(tmp_path: Path) -> None:
72
+ _good_dist(tmp_path)
73
+ (tmp_path / "pylibgrit-0.1.0-cp311-abi3-macosx_11_0_arm64.whl").unlink()
74
+ _touch(tmp_path, "pylibgrit-0.1.0-cp311-cp311-macosx_11_0_arm64.whl")
75
+ errors = check_inventory(tmp_path)
76
+ assert any("abi3" in e for e in errors), errors
77
+
78
+
79
+ def test_version_mismatch_fails(tmp_path: Path) -> None:
80
+ _good_dist(tmp_path)
81
+ (tmp_path / "pylibgrit-0.1.0-cp311-abi3-macosx_11_0_arm64.whl").unlink()
82
+ _touch(tmp_path, "pylibgrit-0.2.0-cp311-abi3-macosx_11_0_arm64.whl")
83
+ errors = check_inventory(tmp_path)
84
+ assert any("version" in e for e in errors), errors
@@ -0,0 +1,193 @@
1
+ # CI for pylibgrit (PyO3 + maturin abi3 bindings to grit-lib).
2
+ #
3
+ # Three jobs:
4
+ # lint — cargo fmt/clippy + ruff format/check (fast gate).
5
+ # test — build the extension and run pytest + mypy + stubtest on a
6
+ # Python matrix (3.11 = the abi3 floor, 3.13 = current).
7
+ # build-wheels — build release abi3 wheels per target, verify the abi3 tag,
8
+ # install the wheel into a clean venv and run the FULL suite +
9
+ # stubtest against the INSTALLED package, build+install+smoke the
10
+ # sdist (fail hard), and upload the artifacts.
11
+ #
12
+ # Conventions / choices (see also rust-toolchain.toml, pyproject.toml):
13
+ # * Rust toolchain is pinned to 1.94.1 to match rust-toolchain.toml. We pin
14
+ # dtolnay/rust-toolchain@1.94.1 directly: the `toolchain:` input is only
15
+ # honored by the @master ref, NOT by @stable, so the version goes in the ref.
16
+ # * All cargo/maturin builds use --locked (committed Cargo.lock) for
17
+ # reproducibility, matching the local `--locked` workflow.
18
+ # * setup-uv is pinned to a full version (@v8.2.0): astral-sh/setup-uv
19
+ # publishes only full vX.Y.Z tags, NOT a floating major tag, so `@v8` does
20
+ # not resolve. Bump this pin when updating uv. Dev deps live in
21
+ # [dependency-groups] (PEP 735), installed via `uv sync --group dev`.
22
+ # * build-wheels also installs setup-uv: the sdist step shells out to
23
+ # `uvx maturin sdist`, so uv must be on PATH (harmless on legs that skip it).
24
+ # * macOS (macos-14 arm64) is best-effort: grit-lib is
25
+ # Unix-oriented and the deps are pure-Rust, so it is expected to build, but
26
+ # fail-fast is disabled so a macOS hiccup does not mask Linux results.
27
+ # * Wheel coverage mirrors most of release.yml's grid: linux glibc x86_64/aarch64,
28
+ # linux musl x86_64 (Alpine import-smoke only), macOS arm64. The emulated
29
+ # musl-aarch64 leg is release-only (kept off per-PR CI to avoid a second slow QEMU
30
+ # build). macOS Intel (x86_64) is not built anywhere — see release.yml.
31
+ # * aarch64 Linux wheels cross-build inside maturin-action's manylinux
32
+ # container (manylinux: auto) via emulation — no extra QEMU step is needed
33
+ # for the build, but that leg does not run the installed-wheel test suite or
34
+ # the sdist compile (both would require emulating the foreign interpreter /
35
+ # toolchain); the native legs (linux x86_64, macОS) cover that.
36
+ # * The native legs install the built wheel into a CLEAN venv and run the real
37
+ # pytest suite + stubtest against the INSTALLED package — exercising the
38
+ # shipped wheel, not the source tree. The source `python/pylibgrit` is kept off
39
+ # sys.path by running from a temp dir that contains only tests/ + conftest +
40
+ # the pyproject pytest config (pyproject's pythonpath=["."] then points at a
41
+ # dir with no source pylibgrit, so the installed wheel is the only importable
42
+ # copy). These steps fail the job on any test failure.
43
+ # * The sdist is built, installed into a clean venv (which compiles from source
44
+ # via the pinned Rust toolchain), and import-smoked — no `|| true`, so a
45
+ # broken sdist fails the job.
46
+
47
+ name: CI
48
+
49
+ on:
50
+ push:
51
+ branches: [main, master]
52
+ pull_request:
53
+
54
+ jobs:
55
+ lint:
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v6
59
+ - uses: dtolnay/rust-toolchain@1.94.1
60
+ with:
61
+ components: rustfmt, clippy
62
+ - uses: Swatinem/rust-cache@v2
63
+ - uses: astral-sh/setup-uv@v8.2.0
64
+ - run: uv sync --group dev
65
+ - run: cargo fmt --check
66
+ - run: cargo clippy --all-targets --locked -- -D warnings
67
+ - run: uv run ruff format --check .
68
+ - run: uv run ruff check .
69
+
70
+ test:
71
+ runs-on: ubuntu-latest
72
+ strategy:
73
+ fail-fast: false
74
+ matrix:
75
+ python-version: ["3.11", "3.13"]
76
+ steps:
77
+ - uses: actions/checkout@v6
78
+ - uses: dtolnay/rust-toolchain@1.94.1
79
+ with:
80
+ components: rustfmt, clippy
81
+ - uses: Swatinem/rust-cache@v2
82
+ - uses: astral-sh/setup-uv@v8.2.0
83
+ with:
84
+ python-version: ${{ matrix.python-version }}
85
+ - run: uv sync --group dev
86
+ - run: uv run maturin develop --uv --locked
87
+ - run: uv run pytest tests/ -v
88
+ - run: uv run mypy python tests
89
+ - run: uv run python -m mypy.stubtest pylibgrit
90
+
91
+ build-wheels:
92
+ name: build-wheels (${{ matrix.os }} ${{ matrix.target }} ${{ matrix.manylinux }})
93
+ runs-on: ${{ matrix.os }}
94
+ strategy:
95
+ fail-fast: false
96
+ matrix:
97
+ include:
98
+ - os: ubuntu-latest
99
+ target: x86_64
100
+ manylinux: auto
101
+ smoke: true
102
+ - os: ubuntu-latest
103
+ target: aarch64
104
+ manylinux: auto
105
+ smoke: false # cross-built under emulation; foreign interpreter not run here
106
+ - os: ubuntu-latest
107
+ target: x86_64
108
+ manylinux: musllinux_1_2
109
+ smoke: false
110
+ musl: true # import-smoke only in an Alpine container (native docker)
111
+ - os: macos-14
112
+ target: aarch64
113
+ manylinux: auto
114
+ smoke: true
115
+ steps:
116
+ - uses: actions/checkout@v6
117
+ - uses: dtolnay/rust-toolchain@1.94.1
118
+ with:
119
+ # Pre-install the rust-toolchain.toml components so a later host `cargo`/
120
+ # `maturin` call can't trigger a flaky lazy rustup re-add of clippy.
121
+ components: rustfmt, clippy
122
+ # The sdist step (x86_64 only) calls `uvx maturin sdist`; uv must be on
123
+ # PATH. Harmless on the legs that skip that step.
124
+ - uses: astral-sh/setup-uv@v8.2.0
125
+ - uses: PyO3/maturin-action@v1
126
+ with:
127
+ target: ${{ matrix.target }}
128
+ args: --release --locked --out dist
129
+ # glibc legs use `auto` (newer glibc, fine for dev — release.yml pins 2014);
130
+ # the musl leg builds musllinux_1_2; macOS ignores this (Linux-only).
131
+ manylinux: ${{ matrix.manylinux }}
132
+ - name: list wheels
133
+ run: ls -la dist
134
+ - name: verify abi3 tag
135
+ shell: bash
136
+ run: ls dist/*.whl | grep -q abi3 && echo "abi3 OK"
137
+ - name: test the INSTALLED wheel (clean venv, full suite + stubtest)
138
+ if: matrix.smoke
139
+ shell: bash
140
+ run: |
141
+ set -euxo pipefail
142
+ # Clean venv: only the built wheel + test deps. git is preinstalled on
143
+ # GitHub runners (the suite shells out to it via tests/conftest.py).
144
+ python3 -m venv /tmp/wheeltest
145
+ /tmp/wheeltest/bin/python -m pip install --upgrade pip
146
+ /tmp/wheeltest/bin/pip install dist/*.whl pytest mypy
147
+ # Stage ONLY tests/ + the pyproject pytest config in an empty dir, so the
148
+ # source `python/pylibgrit` is NOT importable. pyproject's pythonpath=["."]
149
+ # then resolves to this dir (no source pylibgrit), making the installed wheel
150
+ # the only `import pylibgrit`.
151
+ workdir="$(mktemp -d)"
152
+ cp -r tests "$workdir/"
153
+ cp pyproject.toml "$workdir/"
154
+ cd "$workdir"
155
+ # Prove the import comes from the installed wheel, not a source tree.
156
+ /tmp/wheeltest/bin/python -c "import pylibgrit, os; p = os.path.dirname(pylibgrit.__file__); print('pylibgrit from:', p); assert 'site-packages' in p, p"
157
+ /tmp/wheeltest/bin/python -m pytest tests/ -q
158
+ /tmp/wheeltest/bin/python -m mypy.stubtest pylibgrit
159
+ - name: import-smoke the musl wheel (Alpine container)
160
+ if: matrix.musl
161
+ shell: bash
162
+ run: |
163
+ set -euxo pipefail
164
+ # The full pytest+stubtest suite runs on glibc x86_64 + both macOS legs; this
165
+ # leg just proves the wheel loads and links against musl libc. Native docker
166
+ # (x86_64 host) — no QEMU.
167
+ shopt -s nullglob
168
+ wheel=(dist/*-cp311-abi3-*.whl)
169
+ if [[ ${#wheel[@]} -ne 1 ]]; then
170
+ echo "::error::expected exactly one *-cp311-abi3-*.whl, found ${#wheel[@]}: ${wheel[*]:-<none>}"
171
+ ls -la dist || true
172
+ exit 1
173
+ fi
174
+ docker run --rm -v "$PWD/dist:/dist:ro" python:3.11-alpine \
175
+ sh -c "pip install /dist/${wheel[0]##*/} && python -c 'import pylibgrit; pylibgrit.Repository'"
176
+ - name: build + install + smoke the sdist (fail hard)
177
+ # glibc x86_64 leg only — the musl x86_64 leg also matches os+target.
178
+ if: matrix.os == 'ubuntu-latest' && matrix.target == 'x86_64' && matrix.manylinux == 'auto'
179
+ shell: bash
180
+ run: |
181
+ set -euxo pipefail
182
+ # Build the sdist into dist/ (no `|| true` — a failure must fail the job).
183
+ uvx maturin sdist --out dist
184
+ # Install the sdist into a clean venv: this COMPILES from source, using
185
+ # the Rust toolchain set up for this job.
186
+ python3 -m venv /tmp/sdisttest
187
+ /tmp/sdisttest/bin/python -m pip install --upgrade pip
188
+ /tmp/sdisttest/bin/pip install dist/pylibgrit-*.tar.gz
189
+ /tmp/sdisttest/bin/python -c "import pylibgrit; pylibgrit.Repository"
190
+ - uses: actions/upload-artifact@v7
191
+ with:
192
+ name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
193
+ path: dist
@@ -0,0 +1,289 @@
1
+ # Trusted-publishing release pipeline for pylibgrit (PyO3 + maturin abi3 bindings).
2
+ #
3
+ # Separate from ci.yml so the OIDC `id-token: write` permission never lives in the
4
+ # everyday push/PR workflow. Flow:
5
+ # version-guard — (release only) tag must be vX.Y.Z and equal the crate version.
6
+ # build — 5 targets (linux glibc x86_64/aarch64, linux musl x86_64/aarch64,
7
+ # macOS arm64): build the abi3 wheel, assert exactly one cp311-abi3
8
+ # wheel, import-smoke it (native on glibc-x86_64 + macOS; container on
9
+ # the glibc-aarch64 + musl-x86_64/aarch64 legs, arm64 via QEMU),
10
+ # upload. (No macOS Intel: GitHub's macos-13 runners are deprecated.)
11
+ # sdist — build the sdist with the locked maturin, source-compile +
12
+ # import-smoke it, upload.
13
+ # publish-pypi — (release) provenance + inventory gate, then OIDC publish to PyPI.
14
+ # publish-testpypi (workflow_dispatch) — same gate, OIDC publish to TestPyPI.
15
+ #
16
+ # Conventions (match ci.yml): pinned Rust 1.94.1, --locked builds, setup-uv@v8.2.0
17
+ # (no floating major exists), checkout@v6 / upload-artifact@v7 / download-artifact@v7
18
+ # (node24, v4+ backend). Only the publish action is commit-SHA-pinned (it handles
19
+ # the OIDC credential / upload); other actions follow ci.yml's floating-major style.
20
+
21
+ name: Release
22
+
23
+ on:
24
+ release:
25
+ types: [published] # -> real PyPI
26
+ workflow_dispatch: {} # -> TestPyPI dry-run
27
+
28
+ permissions:
29
+ contents: read
30
+
31
+ concurrency:
32
+ group: release-${{ github.event.release.tag_name || github.ref }}
33
+ cancel-in-progress: false # never cancel a partially-completed publish
34
+
35
+ jobs:
36
+ version-guard:
37
+ name: version guard
38
+ runs-on: ubuntu-latest
39
+ if: github.event_name == 'release'
40
+ steps:
41
+ - uses: actions/checkout@v6
42
+ with:
43
+ persist-credentials: false
44
+ - uses: dtolnay/rust-toolchain@1.94.1
45
+ with:
46
+ # Pre-install rust-toolchain.toml's components: the cargo-metadata call
47
+ # below would otherwise trigger a flaky lazy rustup re-add of clippy
48
+ # (conflict on bin/cargo-clippy) and block the release.
49
+ components: rustfmt, clippy
50
+ - name: Assert tag matches crate version
51
+ shell: bash
52
+ run: |
53
+ set -euo pipefail
54
+ tag="${{ github.event.release.tag_name }}"
55
+ # v1 policy: final releases only, vX.Y.Z (no PEP 440 prereleases — see spec).
56
+ if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
57
+ echo "::error::Release tag '$tag' is not of the form vX.Y.Z (final releases only)."
58
+ exit 1
59
+ fi
60
+ tag_version="${tag#v}"
61
+ crate_version="$(cargo metadata --locked --format-version=1 \
62
+ | python3 -c "import json,sys; print([p['version'] for p in json.load(sys.stdin)['packages'] if p['name']=='pylibgrit'][0])")"
63
+ echo "tag=$tag_version crate=$crate_version"
64
+ if [[ "$tag_version" != "$crate_version" ]]; then
65
+ echo "::error::Tag version ($tag_version) != crate version ($crate_version). Bump Cargo.toml AND Cargo.lock."
66
+ exit 1
67
+ fi
68
+ echo "version-guard OK: $tag_version"
69
+
70
+ build:
71
+ name: build (${{ matrix.os }} ${{ matrix.target }} ${{ matrix.manylinux }})
72
+ needs: [version-guard]
73
+ # version-guard is skipped on workflow_dispatch (its own `if`); allow build to
74
+ # run when the guard either succeeded (release) or was skipped (dispatch), but
75
+ # not when it actually failed.
76
+ if: always() && (needs.version-guard.result == 'success' || needs.version-guard.result == 'skipped')
77
+ runs-on: ${{ matrix.os }}
78
+ strategy:
79
+ fail-fast: false
80
+ matrix:
81
+ include:
82
+ # Linux glibc (manylinux_2_17 via "2014") — native + emulated-arm64 smoke.
83
+ - os: ubuntu-latest
84
+ target: x86_64
85
+ manylinux: "2014"
86
+ smoke: native
87
+ - os: ubuntu-latest
88
+ target: aarch64
89
+ manylinux: "2014"
90
+ smoke: container
91
+ image: python:3.11-slim
92
+ platform: linux/arm64
93
+ # Linux musl (musllinux_1_2) — Alpine container smoke (arm64 via QEMU).
94
+ - os: ubuntu-latest
95
+ target: x86_64
96
+ manylinux: musllinux_1_2
97
+ smoke: container
98
+ image: python:3.11-alpine
99
+ platform: linux/amd64
100
+ - os: ubuntu-latest
101
+ target: aarch64
102
+ manylinux: musllinux_1_2
103
+ smoke: container
104
+ image: python:3.11-alpine
105
+ platform: linux/arm64
106
+ # macOS — native smoke. `manylinux: auto` is a no-op here (Linux-only); it is
107
+ # set only so every leg defines it (unique artifact names; no empty input).
108
+ # (Intel x86_64 is NOT built: GitHub's macos-13 Intel runners are deprecated
109
+ # and queue unreliably — Intel Macs install from the sdist instead.)
110
+ - os: macos-14
111
+ target: aarch64
112
+ manylinux: auto
113
+ smoke: native
114
+ steps:
115
+ - uses: actions/checkout@v6
116
+ with:
117
+ persist-credentials: false
118
+ - uses: dtolnay/rust-toolchain@1.94.1
119
+ with:
120
+ components: rustfmt, clippy
121
+ - uses: PyO3/maturin-action@v1
122
+ with:
123
+ target: ${{ matrix.target }}
124
+ args: --release --locked --out dist
125
+ # glibc legs pin manylinux_2_17 ("2014") for the broadest-compatible tag on
126
+ # RELEASED wheels; musl legs build musllinux_1_2; macOS passes `auto` (no-op).
127
+ # ci.yml uses `auto` for glibc (newer glibc, fine for dev — intentional
128
+ # divergence). Ignored on macOS (manylinux is Linux-only).
129
+ manylinux: ${{ matrix.manylinux }}
130
+ - name: Verify exactly one cp311-abi3 wheel
131
+ shell: bash
132
+ run: |
133
+ set -euo pipefail
134
+ shopt -s nullglob
135
+ wheels=(dist/*-cp311-abi3-*.whl)
136
+ if [[ ${#wheels[@]} -ne 1 ]]; then
137
+ echo "::error::expected exactly one *-cp311-abi3-*.whl, found ${#wheels[@]}: ${wheels[*]:-<none>}"
138
+ ls -la dist || true
139
+ exit 1
140
+ fi
141
+ echo "abi3 wheel OK: ${wheels[0]}"
142
+ - name: Set up Python 3.11 for native smoke
143
+ if: matrix.smoke == 'native'
144
+ uses: actions/setup-python@v5
145
+ with:
146
+ python-version: "3.11"
147
+ - name: Import smoke (native, Python 3.11)
148
+ if: matrix.smoke == 'native'
149
+ shell: bash
150
+ run: |
151
+ set -euxo pipefail
152
+ python -m venv /tmp/smoke
153
+ /tmp/smoke/bin/python -m pip install --upgrade pip
154
+ /tmp/smoke/bin/pip install dist/*-cp311-abi3-*.whl
155
+ /tmp/smoke/bin/python -c "import pylibgrit; pylibgrit.Repository"
156
+ /tmp/smoke/bin/python -c "import pylibgrit, os; p=os.path.dirname(pylibgrit.__file__); assert 'site-packages' in p, p; print('imported from', p)"
157
+ - name: Set up QEMU for emulated arm64 smoke
158
+ if: matrix.smoke == 'container' && matrix.platform == 'linux/arm64'
159
+ uses: docker/setup-qemu-action@v3
160
+ - name: Import smoke (container)
161
+ if: matrix.smoke == 'container'
162
+ shell: bash
163
+ run: |
164
+ set -euxo pipefail
165
+ # One step for all container legs: glibc-arm64 (slim) + musl x86_64/arm64
166
+ # (alpine). `sh -c` works in both slim (dash) and alpine (busybox sh).
167
+ shopt -s nullglob
168
+ wheel=(dist/*-cp311-abi3-*.whl)
169
+ docker run --rm --platform ${{ matrix.platform }} \
170
+ -v "$PWD/dist:/dist:ro" ${{ matrix.image }} \
171
+ sh -c "pip install /dist/${wheel[0]##*/} && python -c 'import pylibgrit; pylibgrit.Repository'"
172
+ - uses: actions/upload-artifact@v7
173
+ with:
174
+ name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
175
+ path: dist
176
+ if-no-files-found: error
177
+
178
+ sdist:
179
+ name: sdist
180
+ needs: [version-guard]
181
+ if: always() && (needs.version-guard.result == 'success' || needs.version-guard.result == 'skipped')
182
+ runs-on: ubuntu-latest
183
+ steps:
184
+ - uses: actions/checkout@v6
185
+ with:
186
+ persist-credentials: false
187
+ - uses: dtolnay/rust-toolchain@1.94.1
188
+ with:
189
+ components: rustfmt, clippy
190
+ - uses: astral-sh/setup-uv@v8.2.0
191
+ - run: uv sync --group dev
192
+ # `uv run maturin` uses the maturin pinned in uv.lock (reproducible release
193
+ # builds), NOT a floating `uvx maturin` as in ci.yml's smoke step — deliberate.
194
+ - name: Build sdist (locked maturin)
195
+ run: uv run maturin sdist --out dist
196
+ - name: Source-compile + import-smoke the sdist
197
+ shell: bash
198
+ run: |
199
+ set -euxo pipefail
200
+ python3 -m venv /tmp/sdisttest
201
+ /tmp/sdisttest/bin/python -m pip install --upgrade pip
202
+ /tmp/sdisttest/bin/pip install dist/pylibgrit-*.tar.gz
203
+ /tmp/sdisttest/bin/python -c "import pylibgrit; pylibgrit.Repository"
204
+ - uses: actions/upload-artifact@v7
205
+ with:
206
+ name: sdist
207
+ path: dist
208
+ if-no-files-found: error
209
+
210
+ publish-pypi:
211
+ name: publish to PyPI
212
+ needs: [build, sdist]
213
+ if: github.event_name == 'release'
214
+ runs-on: ubuntu-latest
215
+ environment: pypi
216
+ permissions:
217
+ id-token: write
218
+ contents: read
219
+ steps:
220
+ - uses: actions/checkout@v6
221
+ with:
222
+ fetch-depth: 0
223
+ persist-credentials: false
224
+ - name: Provenance — built commit must be an ancestor of origin/main
225
+ shell: bash
226
+ run: |
227
+ set -euo pipefail
228
+ # This repo's default branch is `main`; update both publish jobs if renamed.
229
+ git fetch --no-tags origin main
230
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
231
+ echo "::error::commit $GITHUB_SHA is not an ancestor of origin/main; refusing to publish."
232
+ exit 1
233
+ fi
234
+ echo "provenance OK: $GITHUB_SHA is on origin/main"
235
+ - uses: actions/download-artifact@v7
236
+ with:
237
+ pattern: "*"
238
+ merge-multiple: true
239
+ path: dist
240
+ - name: Inventory — exactly 5 wheels + 1 sdist, single version
241
+ shell: bash
242
+ run: |
243
+ set -euo pipefail
244
+ ls -la dist
245
+ python3 .github/scripts/check_release_inventory.py dist
246
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
247
+ with:
248
+ packages-dir: dist
249
+
250
+ publish-testpypi:
251
+ name: publish to TestPyPI
252
+ needs: [build, sdist]
253
+ if: github.event_name == 'workflow_dispatch'
254
+ runs-on: ubuntu-latest
255
+ environment: testpypi
256
+ permissions:
257
+ id-token: write
258
+ contents: read
259
+ steps:
260
+ - uses: actions/checkout@v6
261
+ with:
262
+ fetch-depth: 0
263
+ persist-credentials: false
264
+ - name: Provenance — built commit must be an ancestor of origin/main
265
+ shell: bash
266
+ run: |
267
+ set -euo pipefail
268
+ # This repo's default branch is `main`; update both publish jobs if renamed.
269
+ git fetch --no-tags origin main
270
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
271
+ echo "::error::commit $GITHUB_SHA is not an ancestor of origin/main; refusing to publish."
272
+ exit 1
273
+ fi
274
+ echo "provenance OK: $GITHUB_SHA is on origin/main"
275
+ - uses: actions/download-artifact@v7
276
+ with:
277
+ pattern: "*"
278
+ merge-multiple: true
279
+ path: dist
280
+ - name: Inventory — exactly 5 wheels + 1 sdist, single version
281
+ shell: bash
282
+ run: |
283
+ set -euo pipefail
284
+ ls -la dist
285
+ python3 .github/scripts/check_release_inventory.py dist
286
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
287
+ with:
288
+ repository-url: https://test.pypi.org/legacy/
289
+ packages-dir: dist
@@ -0,0 +1,11 @@
1
+ /target
2
+ /.venv
3
+ __pycache__/
4
+ *.pyc
5
+ *.so
6
+ /dist
7
+ /build
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ *.egg-info/