Skip to content

feat(ci): adopt canonical Rust+Python CI gates (zackees/zccache#835)#1

Merged
zackees merged 7 commits into
mainfrom
feat/canonical-ci-gates-issue-835
Jun 20, 2026
Merged

feat(ci): adopt canonical Rust+Python CI gates (zackees/zccache#835)#1
zackees merged 7 commits into
mainfrom
feat/canonical-ci-gates-issue-835

Conversation

@zackees

@zackees zackees commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Applies the full ruleset from zackees/zccache#835 to the canonical hybrid Rust+Python template so every future gh repo create --template zackees/template-python-rust-cmd consumer inherits the practices by construction. This is the template-first leg of the rollout — backfilling existing repos (zackees/clud, zackees/soldr, zackees/running-process, etc.) comes next, but the template fix is the multiplier.

What landed

  • ci.sh + ci.py PEP 723 dispatcher. Thin bash wrapper + Python script with inline deps. Loads uv run --no-project --script exactly once so every gate avoids the maturin auto-build trap (a bare uv run on a maturin-backed pyproject.toml triggers a full wheel build before the script starts; the flags suppress that).
  • ci/gates/{loc,fmt,clippy,ruff,build,test,action_yaml,action_surface}.py. One file per gate, each exposing def run() -> int. Canonical ordering owned by ci.py::GATE_ORDER. build is the only fatal gate — see rule 7.
  • ci/hooks/{tool_guard,readme_guard,loc_guard,check-on-start}.py. tool_guard extended per rule 4 to ban uv run without --no-project --script outside named build entry points (./test, ./build, ci/build_wheel.py, etc.). readme_guard enforces the 50-line floor per rule 8.
  • .github/workflows/ci.yml rewritten as thin orchestration. 8-platform matrix (linux-x86, linux-x86-musl, linux-arm, linux-arm-musl, mac-x86, mac-arm, windows-x86, windows-arm), every step is ./ci.sh <gate>, continue-on-error: true everywhere except build, final report-failures step collects step outcomes.
  • .claude/settings.json binds all four hooks to PreToolUse / PostToolUse / SessionStart events.
  • action.yml + action/cleanup/action.yml make the repo a real composite action. Both validated by action_yaml (structural) + action_surface (subcommand-vs-binary surface match).
  • tests/test_gates.py locks the def run() -> int contract across all 8 gates — 32 parametrized cases.
  • READMEs added/expanded to ≥ 50 lines for every directory in the tree per the readme_guard floor (.cargo/, .claude/, .github/, .github/workflows/, action/, action/cleanup/, ci/, ci/gates/, ci/hooks/, crates/, crates/template-{core,cli,py}/, docs/, src/, src/template_python_rust_cmd/, tests/).
  • Hygiene. .gitignore extended for Python build artifacts; built _native*.pyd and dist/* untracked.

Rule-by-rule mapping

Rule Where it landed
1. LOC budget as a gate ci/gates/loc.py
2. Linters must NOT trigger a full build ci.sh / ci.py
3. Single bash wrapper for the dispatcher ci.sh
4. Ban unsafe uv run shape via hook ci/hooks/tool_guard.py
5. Reserve full uv run for explicit build entry points BUILD_ENTRY_POINTS in tool_guard.py
6. ci.yml is thin orchestration .github/workflows/ci.yml
7. One runner per platform; continue-past-failure with build fatal ci.yml matrix + report-failures step
8. README guard with 50-line floor ci/hooks/readme_guard.py
9. Hooks vs gates split ci/gates/ vs ci/hooks/
10. Action.yml self-test is cheap, not full re-build ci/gates/action_yaml.py, ci/gates/action_surface.py

Local validation

./ci.sh --list                 # lists 8 gates
./ci.sh loc                    # PASS
./ci.sh fmt                    # PASS
./ci.sh ruff                   # PASS (after auto-format)
./ci.sh build                  # PASS (cargo check workspace)
./ci.sh action_yaml            # PASS
uv run pytest tests/test_gates.py   # 32 passed

tool_guard hook self-tests:

  • bare cargo build → DENY (exit 2, structured permission decision payload)
  • ./ci.sh fmt → PASS
  • ./test → PASS (named build entry)
  • uv run --no-project --script ci/build_wheel.py → PASS

Test plan

  • CI matrix completes on all 8 platforms (this PR is the first run that exercises the full matrix).
  • action_yaml gate passes against the new action.yml + action/cleanup/action.yml.
  • action_surface gate passes — verifies template-cli --help covers everything action.yml shells out to.
  • tests/test_gates.py passes (32 cases).
  • Verify gh repo create --template zackees/template-python-rust-cmd ... inherits all the above intact.

Notes

  • The two existing ci/lint.py and ci/test.py shims were removed; ./lint and ./test now route directly through ./ci.sh.
  • ci/build_wheel.py and ci/publish.py kept as-is — they're the documented opt-in to the full maturin context (per rule 5).
  • This template currently has placeholder Rust crates; the composite action's smoke test will work end-to-end only once the package is published. The gates themselves don't require the package on PyPI.

🤖 Generated with Claude Code

zackees and others added 7 commits April 8, 2026 12:38
Apply the issue's full ruleset to the canonical hybrid template so
every future `gh repo create --template zackees/template-python-rust-cmd`
inherits the practices by construction.

What lands:

- `ci.sh` + `ci.py` PEP 723 dispatcher. `--no-project --script` lives
  in one place so bare `uv run` never triggers the maturin auto-build
  trap.
- `ci/gates/{loc,fmt,clippy,ruff,build,test,action_yaml,action_surface}.py`.
  One `def run() -> int` per gate; canonical ordering owned by
  `ci.py::GATE_ORDER`. `build` is the only fatal gate.
- `ci/hooks/{tool_guard,readme_guard,loc_guard,check-on-start}.py`.
  `tool_guard` extended to ban `uv run` without `--no-project --script`
  outside named build entry points. README floor at 50 lines.
- `.github/workflows/ci.yml` rewritten as thin orchestration: 8-platform
  matrix, every step is `./ci.sh <gate>`, continue-past-failure with
  a final `report-failures` summary.
- `.claude/settings.json` binds all four hooks to their events.
- `action.yml` + `action/cleanup/action.yml` make the repo a real
  composite action. Both validated by `action_yaml` (structural) +
  `action_surface` (subcommand-vs-binary match).
- `tests/test_gates.py` locks the `def run() -> int` contract across
  all 8 gates (32 parametrized cases).
- READMEs added/expanded to >=50 lines for every directory in the
  tree per the readme_guard floor.
- Untracks built artifacts (`_native*.pyd`, `dist/*`, `__pycache__/`).

Local validation:
- ./ci.sh --list, loc, fmt, ruff, build, action_yaml all green.
- tool_guard correctly denies bare `cargo`, accepts ./ci.sh / ./test /
  uv run with protective flags / named build entry points.
- 32/32 gate contract tests pass.

Rule 10 (action gates) addressed by adding a real composite action shape;
the gates are wired against actual `action.yml` + `action/cleanup/
action.yml`, not stubs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
actions/checkout preserves the index mode of tracked files; ci.sh and
the other root entry points were committed with mode 100644, so the
runners hit `Permission denied` before any gate ran. `git update-index
--chmod=+x` on ci.sh, ci.py, install, lint, test, publish.

Also drop the dead for-loop in the report-failures step that interpolated
`${{ join(steps.*.outcome, ' ') }}` into a discarded variable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three blocking failures from the last run:

1. ruff format: 5 files would be reformatted on Linux runners — CRLF
   line endings from Windows commits. Add `.gitattributes` with
   `* text=auto eol=lf` plus explicit per-extension entries so the
   tree stays LF everywhere. `git add --renormalize` applied to the
   existing files.

2. tests/test_gates.py: `import ci.gates.action_yaml` raised
   `ModuleNotFoundError: No module named 'yaml'` because the gate's
   `import yaml` ran at module load time and pytest's venv doesn't
   install pyyaml. Wrap the import in try/except so the module stays
   importable; `run()` fails fast with a clear message if pyyaml
   wasn't provisioned by the ci.py dispatcher.

3. action_surface: failed when no `template-cli` binary existed. The
   `build` gate is `cargo check` (no link), so the binary never gets
   produced in CI. Downgrade missing-binary to a skip + warning since
   the structural contract is already covered by `action_yaml`; the
   runtime check is best-effort against whatever build is locally
   available.

Local re-validation:
  ./ci.sh ruff           ok (25 files already formatted)
  ./ci.sh action_yaml    ok
  ./ci.sh action_surface ok (skipped, no binary)
  pytest tests/test_gates.py — 32/32 (without pyyaml installed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`>=0.8` resolved to different ruff versions between local and CI,
which produced different `ruff format --check` verdicts on the same
tree. CI keeps reporting 5 files would be reformatted while local
ruff (0.15.18) reports clean.

Pin to ruff==0.15.18 so the gate is deterministic. Bumping is now a
one-line change with explicit intent.
`uv run` was silently reusing a cached older ruff locally despite the
==0.15.18 pin, masking the format diff. The real issue: ruff walked up
to a parent-directory `pyproject.toml` and inherited a `[tool.ruff]`
block that doesn't belong to this repo. Local saw config A, CI saw
default config — different verdicts on the same file.

`--isolated` makes ruff ignore ancestor pyproject.toml entirely, so
the gate evaluates the same bytes everywhere. Apply the format pass
that the unconfigured ruff actually wants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
macos-14 (and likely other future runners) defaults to Python 3.14,
which pyo3-ffi 0.23 rejects: "configured Python interpreter version
(3.14) is newer than PyO3's maximum supported version (3.13)".

Pin python3 -> 3.13 in two ways:
  - setup-uv `python-version: 3.13` so uv operations use it.
  - explicit `uv python install 3.13` + GITHUB_PATH + PYO3_PYTHON env
    so pyo3's build.rs finds 3.13 even though it isn't a uv
    invocation.

Drop both once the workspace bumps pyo3 past 0.24.
@zackees zackees merged commit 2fc41aa into main Jun 20, 2026
7 of 8 checks passed
@zackees zackees deleted the feat/canonical-ci-gates-issue-835 branch June 20, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant