Files
magnus919_agent-skills/opensource-contributions/references/ci-debugging-loop.md
T
Magnus Hedemark c5c4d2434a feat: add opensource-contributions skill
Comprehensive open source contribution guidance with progressive disclosure.
Restructured from a single ~23K-token monolithic SKILL.md into a concise
~1.2K-token orchestrator with 10 focused reference files.

Changes:
- SKILL.md: 1,720 lines → 100 lines (AgentSkills.io compliant frontmatter)
- 10 new reference files covering phases 0a-4, pitfalls, default posture
- Portable PR template compliance checker script (stdlib-only)
- All personal context scrubbed for public export
- AGENTS.md + README.md updated with trigger table entry

Signed-off-by: Magnus Hedemark <magnus919@pm.me>
2026-05-28 21:43:30 -04:00

252 lines
9.1 KiB
Markdown

# CI Debugging Loop
A systematic approach to fixing CI failures on a Python project. Use this anytime CI turns red after a push.
## Step 1: Read the CI Logs
```bash
# Get the failed run ID
gh run list --limit 3 --json conclusion,displayTitle,status,databaseId
# View failed steps
gh run view <RUN_ID> --log-failed
# View full output for a specific job
gh run view <RUN_ID> --log
```
**What you're looking for:** The exact test name that failed, the assertion error, and any clues about why (env var mismatch, missing method, import error, etc.).
## Step 2: Reproduce Locally
```bash
# Reproduce just the failing test
pytest -xvs tests/test_file.py::test_name
# If it passes locally but fails in CI, check for environment differences:
# - Python version (CI may use 3.11 vs your 3.13)
# - Installed dependencies (CI installs fresh; you may have stale packages)
# - Environment variables (CI sets HF_HUB_OFFLINE=1, etc.)
# - Entry-point metadata (`pip install -e .` may cache old entry points)
```
## Step 3: Isolate Pre-existing vs New Failures
Before assuming your change broke something, check if the test was already failing:
```bash
# Stash your changes, run the failing test on clean main
git stash
pytest -xvs tests/test_file.py::test_name
git stash pop
```
| Result | Diagnosis |
|--------|-----------|
| Fails on clean main too | **Pre-existing** — your change didn't cause it. Fix separately or skip. |
| Passes on clean main, fails with your changes | **Regression** — your change caused it. Debug the interaction. |
| Different test fails on clean main | **Test ordering / flaky** — env sensitivity or timing race. Run again. |
## Step 4: Root Cause Categories
### Env Var Leakage
The user's development environment may set env vars that leak into tests. Common culprits:
- `CASHEW_*` vars from a running Hermes session override config defaults in tests
- `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc. causing unintended API calls
- Path vars that influence import resolution
**Fix pattern:** Add an autouse fixture in `conftest.py` that strips the leaking vars:
```python
@pytest.fixture(autouse=True)
def _clear_leaking_env_vars(monkeypatch: pytest.MonkeyPatch):
for key in list(os.environ):
if key.startswith("CASHEW_"):
monkeypatch.delenv(key, raising=False)
```
Mirror the existing pattern already used by the project (e.g., `HF_HUB_OFFLINE=1` in conftest).
### Stale Package Metadata
`pip install -e .` installs entry point metadata that persists across source changes. If you change `pyproject.toml`'s entry point but don't reinstall, the old metadata is used:
```bash
# Check what's actually registered
python3 -c "import importlib.metadata as im; eps=list(im.entry_points(group='hermes_agent.plugins')); [print(f'{ep.name} = {ep.value}') for ep in eps]"
```
**Fix:** Update the installed entry point metadata directly, or reinstall:
```bash
# Option A: Patch the installed entry_points.txt
# Find the dist-info directory:
python3 -c "import importlib.metadata as im; d=im.distribution('your-package'); print(d._path)"
# Edit entry_points.txt in that directory
# Option B: Reinstall
pip install -e ".[dev]" --force-reinstall --no-deps
```
**Specific gotcha with entry point suffixed targets:** An entry point declared as `module.path` (without `:suffix`) resolves to a **module** via `ep.load()`. If declared as `module.path:function`, it resolves to the **function**. If you switch between these, any tests that call `ep.load()` and expect a particular type will break. On macOS, stale dist-info may retain the old format — always check with `python3 -c "import importlib.metadata as im; ..."` to see what's actually registered.
### Removed/Refactored Methods
After a major refactor, tests that reference old method names or monkeypatch old internals will fail:
```bash
# Check if the method still exists
python3 -c "from your_module import YourClass; print(hasattr(YourClass, 'old_method_name'))"
```
**Fix pattern:** Update the test to use the new API surface. If mocking internal methods, mock at the boundary instead (the public API or the dependency's interface).
### Immutable Types (Python 3.11+)
On modern Python, some C-level types (notably `sqlite3.Connection`) are immutable. `monkeypatch.setattr` appears to succeed but the teardown raises `TypeError`:
```python
# Detection: try to set a dummy attribute
try:
sqlite3.Connection._test_mutability = lambda: None
del sqlite3.Connection._test_mutability
except TypeError:
# Immutable — can't use monkeypatch on this type
pass
```
**Fix:** Detect immutability and adapt the test — either skip the mock and test the fallback path directly, or mock at a different level.
### Direct Dependency in PyPI Package
If `pyproject.toml` has a git+SHA pinned dependency (e.g., `package @ git+https://github.com/user/repo.git@abc123`), the PyPI publish step will reject the package with `400 Can't have direct dependency`. The build and wheel-smoke steps pass — only the upload fails.
**Fix:** Check if the dependency is available on PyPI. If yes, switch to a version specifier:
```
# Before (blocks PyPI):
"package @ git+https://github.com/user/repo.git@abc123"
# After:
"package>=1.0.0,<2.0.0"
```
## Step 5: Fix and Verify
1. Make the minimal fix
2. Run the failing test locally: `pytest -xvs tests/test_file.py::test_name`
3. Run the full test suite: `pytest`
4. Commit with conventional commit + DCO sign-off: `git commit -s -m "fix: description"`
5. Push: `git push`
## Step 6: Verify CI Passes
After pushing, **check CI status proactively** — don't wait for the user to tell you it's red:
```bash
# Wait for CI to start and complete
gh run watch <RUN_ID> --exit-status
# Or check status periodically
gh run list --limit 1 --json conclusion,status
```
If still red, go back to Step 1. Repeat until green.
## Step 7: Multiple Fixes, Multiple Commits
When fixing a series of related CI issues, use **one commit per root cause** — not one commit per test file, and definitely not one mega-commit. This keeps the history reviewable and makes it easy to revert individual fixes if needed.
Example from a real session:
```
fix: switch dependency from git+SHA to PyPI specifier # unblocks release
fix: handle repr-style list env vars in config # fixes config parsing
fix: align entry-point test with module-load contract # fixes test gap
fix: update macos-fallback test for refactored retrieval # fixes stale mock
```
Each commit is a single logical change, has its own DCO sign-off, and could stand alone.
## Step 8: Structural Release-Workflow Fixes
After fixing the immediate CI failure, consider whether the *workflow itself* has a structural gap that allowed the bad state to reach production (or PyPI).
### Gate Release Workflow Behind Tests
The most common gap: the release workflow runs on tag push but has **no dependency on the test workflow**. A broken tag can publish to PyPI.
**Fix pattern:** Add a `test` job as a prerequisite to `build` (and thus `publish-pypi`):
```yaml
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
env:
HF_HUB_OFFLINE: "1"
# … any env vars your tests need
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- run: pip install -e ".[dev]"
- run: pytest -xvs
build:
name: Build distribution
needs: test # ← never build if tests fail
# … rest of build steps
publish-pypi:
name: Publish to PyPI
needs: build # ← never publish if build fails
if: contains(github.ref_name, '-') == false # skip RC tags
# … publish steps
```
### Add workflow_dispatch for Retries
Without `workflow_dispatch`, the only way to retry a failed release is to delete and recreate the tag — which rewrites history. Add a manual trigger:
```yaml
on:
push:
tags:
- "v*"
workflow_dispatch: # ← manual retry button in the Actions tab
```
This lets you retry a failed publish without touching the tag. If the release process has a "publish to PyPI" step that depends on an environment with protection rules, `workflow_dispatch` will respect those rules on manual runs too.
### Tag-Delete-and-Recreate Cycle
If a release does fail and `workflow_dispatch` wasn't yet added (or the tag itself was pushed before the fix was in):
```bash
# Delete the broken tag
git tag -d vX.Y.Z
git push --delete origin vX.Y.Z
# Fix the issue, push the fix
git add …
git commit -s -m "fix: root cause"
git push
# Recreate the tag on the new HEAD
git tag vX.Y.Z
git push origin vX.Y.Z
```
This re-triggers the release workflow. Only do this for failed releases — never for successful ones (would unpublish the package if downstream consumers already depend on it).
## Quick Reference
| Step | Command |
|------|---------|
| Read CI logs | `gh run view <ID> --log-failed` |
| Run single test | `pytest -xvs tests/file.py::test_name` |
| Check pre-existing | `git stash && pytest ... && git stash pop` |
| Check installed entry point | `python3 -c "import importlib.metadata as im; ..."` |
| Full test suite | `pytest` |
| Commit + push | `git commit -s -m "fix: ..." && git push` |
| Watch CI | `gh run watch <ID> --exit-status` |