Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .cargo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# `.cargo/`

Per-repo cargo configuration. The single file here is `config.toml`,
which sets workspace-wide build settings cargo picks up without
needing a CLI flag every time.

## Why this directory exists at all

`Cargo.toml` declares dependencies and crate metadata. **`.cargo/
config.toml`** is where you put settings that affect *the build
process itself* — target-specific linker flags, build-script
environment, registry overrides, rustflags for the whole workspace.

The two files have different audiences:

- `Cargo.toml` is the contract for consumers of this workspace.
- `.cargo/config.toml` is build-system policy for this checkout.

## Common contents

Typical entries you might see here, depending on what the template
ends up needing:

```toml
[build]
# Workspace-wide rustflags. Be careful — this affects every crate.

[target.x86_64-pc-windows-msvc]
# Per-target linker settings.

[net]
# Registry/git fetch policy. Useful for CI but not generally.

[profile.release]
# Override the default release profile across the workspace.
```

## What does NOT belong here

- **Per-user paths.** Anything user-specific lives in `~/.cargo/
config.toml`. Checked-in config should be reproducible across
contributors.
- **Secrets / tokens.** Never. The CI registry token comes from a
GitHub Actions secret, not from this file.
- **Toolchain pinning.** That's `rust-toolchain.toml` at the repo
root.

## Interaction with the workflow

The CI workflow's cache key (in `.github/workflows/ci.yml`) hashes
both `Cargo.lock` and `rust-toolchain.toml`. If you add a build flag
here that changes ABI / artifact shape, also tweak the cache key so
stale caches don't bleed in across config changes.

## Reference

The full schema for `config.toml` is in the
[cargo book](https://doc.rust-lang.org/cargo/reference/config.html).
Stick to what you actively need — every extra knob here is a
configuration drift surface.
12 changes: 12 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[profile.dev]
debug = "line-tables-only"
incremental = true

[profile.debug]
inherits = "dev"
debug = "full"
incremental = true

[profile.test]
debug = "line-tables-only"
incremental = false
58 changes: 58 additions & 0 deletions .claude/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# `.claude/`

Per-project configuration for Claude Code. Right now this directory
just holds `settings.json`, which wires the four canonical hooks
defined in `ci/hooks/`.

## What's wired

`settings.json` binds each hook to its event:

| Event | Hook | Matcher |
|-------------|-----------------------------------|----------------|
| PreToolUse | `ci/hooks/tool_guard.py` | Bash/Shell/PS |
| PostToolUse | `ci/hooks/readme_guard.py` | Edit/Write |
| PostToolUse | `ci/hooks/loc_guard.py` | Edit/Write |
| SessionStart| `ci/hooks/check-on-start.py` | any |

All hooks are invoked through `uv run --no-project --script`, the
same discipline the rest of the repo follows. This avoids the maturin
auto-build trap that would otherwise fire on every hook execution
(see `ci.sh` for the rationale).

## Why hooks live in the repo, not user settings

Two reasons:

1. **Consistency.** Every contributor sees the same gates fire. The
hook bodies are version-controlled, reviewed in PRs, and evolve
with the project. User-level hook configuration is fine for
personal helpers but doesn't survive a fresh checkout or a
teammate joining.

2. **Hooks ARE the contract.** A bare `cargo build` triggering soldr
discovery rules, a `python` invocation routing through `uv`, the
LOC budget — these are project policy, not user preference. They
belong with the source they protect.

## What's intentionally NOT here

- **Slash commands / skills.** Those live in `~/.claude/skills/` or
`~/.claude/commands/` per user. If a project ever needs a skill
bundled, this directory is a fine place to put it (`.claude/skills/`
is picked up by Claude Code automatically).

- **Custom agents.** Same as skills — user-level by default.

- **API keys or secrets.** Never. Use env vars or a secret manager.

## Settings.json discipline

If you add a new hook to `ci/hooks/<name>.py`, register it here. If
you remove one, remove the binding. The
[`fewer-permission-prompts` skill](https://docs.claude.com/en/docs/claude-code/skills)
can help if local development hits permission churn — keep additions
under PreToolUse-allowlist, not under hook bypass.

For the full hook schema, see the [Claude Code hooks
documentation](https://docs.claude.com/en/docs/claude-code/hooks).
42 changes: 42 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Shell|PowerShell",
"hooks": [
{
"type": "command",
"command": "uv run --no-project --script ci/hooks/tool_guard.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "uv run --no-project --script ci/hooks/readme_guard.py"
},
{
"type": "command",
"command": "uv run --no-project --script ci/hooks/loc_guard.py"
}
]
}
],
"SessionStart": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "uv run --no-project --script ci/hooks/check-on-start.py"
}
]
}
]
}
}
27 changes: 27 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Normalize line endings to LF for all text files. Without this the
# Linux ruff format check fails on CRLF-terminated files committed
# from Windows.
* text=auto eol=lf

# Shell scripts and Python sources must be LF.
*.sh text eol=lf
*.py text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.json text eol=lf

# Treat these as binary so git doesn't try to convert line endings.
*.png binary
*.jpg binary
*.gif binary
*.pdf binary
*.so binary
*.dylib binary
*.pyd binary
*.exe binary

# Lockfiles stay as-is.
Cargo.lock text eol=lf
uv.lock text eol=lf
56 changes: 56 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `.github/`

GitHub-specific metadata: the CI workflow that calls `./ci.sh` per
platform, issue and PR templates (if any), and CODEOWNERS hooks. Kept
deliberately thin per [zackees/zccache#835 rule 6](https://github.com/zackees/zccache/issues/835):
every CI step is a single `run: ./ci.sh <gate>` line. The actual logic
lives in `ci/gates/*.py` so the same bytes run on a developer laptop.

## Layout

```
.github/
└── workflows/
└── ci.yml # 8-platform matrix; every step is `./ci.sh <gate>`
```

## Workflow shape

`workflows/ci.yml` declares one matrix entry per platform target:

- `linux-x86` (ubuntu-latest, x86_64-unknown-linux-gnu)
- `linux-x86-musl` (ubuntu-latest, x86_64-unknown-linux-musl)
- `linux-arm` (ubuntu-24.04-arm, aarch64-unknown-linux-gnu)
- `linux-arm-musl` (ubuntu-24.04-arm, aarch64-unknown-linux-musl)
- `mac-x86` (macos-13, x86_64-apple-darwin)
- `mac-arm` (macos-14, aarch64-apple-darwin)
- `windows-x86` (windows-latest, x86_64-pc-windows-msvc)
- `windows-arm` (windows-11-arm, aarch64-pc-windows-msvc)

Each runner sets up uv + rustup, then runs each gate as a separate
named step with `continue-on-error: true` — except `build`, which is
fatal (a failing build short-circuits downstream gates because they'd
produce noise against an uncompiled tree). A final reporting step
collects step outcomes and exits non-zero with the list of failed
gates so the PR check surface is "exactly these gates need attention,"
not "the run failed, hunt through logs."

## Why this folder is intentionally small

The historic anti-pattern is multi-line shell embedded in YAML —
unlintable, untestable, only validates when CI runs. Pushing logic
into `ci/gates/<name>.py` makes each gate `import ci.gates.fmt;
ci.gates.fmt.run()` from a future `tests/test_gates.py`. The
workflow file only has to know which gates exist (it lists them by
name), not what they do.

## Adding a new gate to CI

1. Write the gate at `ci/gates/<name>.py` with `def run() -> int`.
2. Register it in `ci.py::GATE_ORDER`.
3. Add a row under the matrix step list in `workflows/ci.yml` that
runs `./ci.sh <name>` with the appropriate `continue-on-error`
policy.

No multi-line shell — if you find yourself reaching for
`run: |\n ...`, the logic belongs in the gate, not the workflow.
66 changes: 66 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `.github/workflows/`

GitHub Actions workflows. Currently just `ci.yml`. Other workflows
(release-auto, build-dist, etc.) get added here as the template evolves.

## `ci.yml`

The canonical CI workflow. One runner per platform, every step is a
`run: ./ci.sh <gate>` line — no multi-line shell. The 8-platform matrix
covers:

- linux-x86, linux-x86-musl
- linux-arm, linux-arm-musl
- mac-x86, mac-arm
- windows-x86, windows-arm

### Step contract

Every gate step has the same shape:

```yaml
- name: <gate>
id: <gate>
continue-on-error: true # except `build`
run: ./ci.sh <gate>
```

A final `report-failures` step inspects `steps.<id>.outcome` for each
gate and exits 1 with a summary if anything failed. The exception is
`build`: it's the only fatal step. A failing build halts the matrix
job because every downstream gate would emit noise against an
uncompiled tree (see [zccache#835 rule 7](https://github.com/zackees/zccache/issues/835)).

### Caching policy

- `actions/setup-python` with `uv` cache key keyed off `uv.lock`.
- Rust toolchain cache keyed off `rust-toolchain.toml`.
- `target/` cache keyed off `Cargo.lock` + the toolchain version.

The cargo cache is shared across `fmt`, `clippy`, `build`, and `test`
within a single runner because they all live on the same matrix entry —
splitting them across separate matrix entries would defeat that
amortization (and is the historical anti-pattern this workflow shape
fixes).

### Local reproducibility

Anything you can do here, you can do locally:

```
./ci.sh fmt
./ci.sh clippy
./ci.sh all
```

The bytes are identical because both paths call the same
`ci/gates/<name>.run()`.

### Adding a workflow

New workflows belong here as `.github/workflows/<name>.yml`. Keep them
thin: orchestration only, logic in Python under `ci/`. If a workflow
needs a script that isn't a gate (e.g., a release pipeline step), put
it under `ci/` with a `def main() -> int` entry point and call it the
same way: `run: ./ci.sh` is fine for gates, or a dedicated
`run: uv run --no-project --script ci/<name>.py` for one-offs.
Loading
Loading