Skip to content

feat(packaging): ship a runnable pip install cap-evolve release path (#125) - #250

Open
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-125-pypi-publish
Open

feat(packaging): ship a runnable pip install cap-evolve release path (#125)#250
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-125-pypi-publish

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #125

pip install cap-evolve now produces an install that can actually run. The distribution
is renamed cap-evolve-corecap-evolve, the built artifact carries the runtime
data a run cannot start without, and a release workflow builds + verifies + (on a tag)
publishes it.

⚠️ Nothing was published

No upload happened and none can happen from this branch. I have no authorization to
publish, and publishing is irreversible (a PyPI version can be yanked but never
replaced). Concretely:

  • no tag was pushed — the PyPI job is if: startsWith(github.ref, 'refs/tags/v');
  • gh secret list shows no PyPI or TestPyPI credentials in the repo (only
    ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, BOBSHELL_API_KEY,
    SKILLBERRY_BOT_TOKEN), and the environments are copilot + one unrelated one. The
    TestPyPI dry-run was therefore not run
    , per the instruction not to create or request
    credentials. The publish-testpypi job exists and is manual-dispatch only.

Everything verifiable without write intent was verified: build, twine check, artifact
contents, and a real end-to-end run from a fresh venv installed from the artifact.

The bug this fixes (the reason the verify job is not optional)

The core is pure .py, but a run also needs data that lives outside the package:
skills/optimizers/registry.yaml, skills/_registry/manifest.json, every skill's
scripts/run.py, and templates/project/. A wheel built from the old pyproject.toml
shipped none of it. That is #193's install.sh hole — a stock install that looks fine
and cannot run an optimizer — in pip shape.

I built that broken wheel deliberately as a negative control:

=== NEGATIVE CONTROL: wheel with no bundled data ===
{"error": "skills dir not found; set CAPEVOLVE_SKILLS_DIR or --skills-dir"}

Worth stating why the run has to happen outside the checkout with HOME redirected
(#208's lesson): _find_skills_dir() falls back to ./.claude/skills, ~/.claude/skills,
then a parent-walk to the repo's own skills/. My first attempt ran inside my own $HOME
and silently picked up a stale ~/.claude/skills — it failed with
hill-climb: error: unrecognized arguments: --capabilities and told me nothing about the
wheel. A green run in the repo tree proves nothing.

What data files were missing, and why they are in now

File Why a run dies without it
skills/_registry/manifest.json cap-evolve run resolves every phase/algorithm skill through it
skills/optimizers/registry.yaml resolves optimizer_skill to a backend (#193's exact file)
skills/**/scripts/{run,check,abstract}.py the phases and algorithms themselves are skill scripts
skills/**/SKILL.md, meta.yaml, references/** the guidance the harness copies into each iteration's workdir
templates/project/** scaffolding + optimizer/INSTRUCTIONS.md, which harness.py renders per iteration

They ship as cap_evolve/_bundled/{skills,templates}symlinks to the repo trees,
declared as package-data. setuptools follows them into both wheel and sdist, so there is
exactly one copy on disk and the packaged data can never drift from what the repo tests.
core/tests/test_packaging.py asserts they stay symlinks rather than becoming a second copy.

dashboard/frontend/dist/ (#188) is deliberately not included: it belongs to
capevolve-dashboard, whose resolve_static_dir() looks for it at
dashboard/frontend/dist relative to its own package — putting it in this wheel would
duplicate ~950 KB that nothing would read. The dashboard is the [dashboard] extra, and
every run still writes a self-contained static dashboard.html with no backend at all.

How the code changed

resources.resource_root() returns the repo root or the packaged bundle, whichever is
real ($CAPEVOLVE_RESOURCE_ROOT overrides both). It replaces the six
Path(__file__).resolve().parents[2] repo-layout assumptions in harness.py and the
CLI's _find_skills_dir() parent-walk — root cause, one place, rather than a special case
per call site. A test fails if parents[2] reappears in the core.

Version single-source

cap_evolve.__version__ is authoritative. pyproject.toml reads it via
[tool.setuptools.dynamic], so there is no second literal to forget; the test rejects a
hardcoded version = in pyproject. CITATION.cff's version: must match it (asserted),
and the release workflow refuses to build if the git tag disagrees with __version__.

Does #186 block the release?

No — it is fixed here, because it is release metadata and this is the release PR.
CITATION.cff said date-released: 2026-06-14 while v0.1.0's GitHub release published
2026-07-27 (tag commit 2026-07-26). Both dates verified via
gh release view v0.1.0 (publishedAt: 2026-07-27T10:51:37Z) and git log -1 v0.1.0.
Now 2026-07-27, matching #101's Keep-a-Changelog reasoning (date the release, not the
tag commit), and guarded by a test. #186 can be closed by this PR.

Expected merge order

  1. CHANGELOG: stale 0.46→0.80 number, no released-version section, features filed under "Fixed" #101 (PR docs(changelog): correct stale 0.46→0.80, add real [0.1.0] section, move features to Added #184) — the release notes slice needs a real ## [0.1.0] section.
    I verified my slice against docs(changelog): correct stale 0.46→0.80, add real [0.1.0] section, move features to Added #184's actual CHANGELOG (see Verification): 119 lines,
    zero link definitions. Merge docs(changelog): correct stale 0.46→0.80, add real [0.1.0] section, move features to Added #184 before pushing any tag. Merging this PR first is
    fine; the workflow only reads the CHANGELOG on a tag, and it fails loudly on an
    empty slice rather than shipping blank notes.
  2. Unify skill count (18 vs 19) and algorithm count (3 vs 4) across all surfaces #102 (PR docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189) — its counts guard. core/README.md (the page PyPI renders) states
    20 skills · 5 algorithms (3 run-executable + 2 agent-mode) · 14 optimizer backends;
    I recomputed all four from the filesystem on this branch by docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189's own counting rule
    and they match, so the two PRs agree in either order.
  3. This PR.
  4. Then, and only then, a human does the release below.

What a human must do to release

Nothing here has been done. Steps 1–2 are one-time.

1. Configure PyPI Trusted Publishing (one time, no token to store).
On https://pypi.org/manage/account/publishing/ add a pending publisher:
PyPI project cap-evolve · owner skillberry-ai · repo cap-evolve ·
workflow release.yml · environment pypi.
(The name cap-evolve is currently unregistered — GET /pypi/cap-evolve/json → 404 —
so the first publish claims it. cap-evolve-core is also unpublished.)

2. Create the pypi GitHub environment (and testpypi if you want the dry-run):

gh api -X PUT repos/skillberry-ai/cap-evolve/environments/pypi
gh api -X PUT repos/skillberry-ai/cap-evolve/environments/testpypi   # optional

3. Dry run — build + verify only, uploads nothing:

gh workflow run release.yml --repo skillberry-ai/cap-evolve -f target=none
gh run watch --repo skillberry-ai/cap-evolve

Both verify (wheel) and verify (sdist) must print sealed test_reward 1.0.

4. Optional TestPyPI rehearsal (needs step 2's testpypi env + a TestPyPI pending
publisher; this DOES upload, to TestPyPI only):

gh workflow run release.yml --repo skillberry-ai/cap-evolve -f target=testpypi
pip install --index-url https://test.pypi.org/simple/ cap-evolve && cap-evolve version

5. Confirm the CHANGELOG has a section for the version (PR #184 must be merged):

v=$(PYTHONPATH=core python -c 'import cap_evolve; print(cap_evolve.__version__)')
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {on=1;next} on && /^## \[/{exit} on' CHANGELOG.md | head

6. THE IRREVERSIBLE STEP — push the tag. A PyPI version can be yanked but never
replaced. Bump __version__ first if 0.1.0 is not the version you mean to publish
(v0.1.0 already exists as a tag, so a re-release needs a new version):

git checkout main && git pull
git tag -a v0.1.0 -m "v0.1.0" && git push origin v0.1.0
gh run watch --repo skillberry-ai/cap-evolve

That builds, verifies, publishes to PyPI, and cuts the GitHub Release with the
CHANGELOG-sliced notes.

7. Verify the published package on a clean machine:

python3 -m venv /tmp/v && /tmp/v/bin/pip install cap-evolve && /tmp/v/bin/cap-evolve version

8. Flip the docs. docs/INSTALL.md carries a "Not published yet" callout on the PyPI
path — delete it once step 7 passes, and update README.md / site/getting-started.html
quickstarts to lead with pip install cap-evolve (the ./core from-source path stays,
for dev and for examples/).

Verification

Full pasted output is in the 🔬 Evidence comment. Summary:

Test suite — 187 passed, 0 failed (baseline 179 + 8 new packaging tests):

........................................................................ [ 38%]
........................................................................ [ 77%]
...........................................                              [100%]
187 passed in 65.40s (0:01:05)

Build + twine check + artifact contents:

Successfully built cap_evolve-0.1.0.tar.gz and cap_evolve-0.1.0-py3-none-any.whl
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED
wheel: 247 entries, all required data files present, no detritus
sdist: 383 entries, all required data files present, no detritus

Fresh venv from the WHEEL, run from /tmp/finw-7Tvt with HOME redirected:

{
  "run_dir": ".capevolve/run_final",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3,
  "dashboard": ".capevolve/run_final/dashboard.html"
}

Fresh venv from the SDIST (sdists commonly miss data files — this one does not),
run from /tmp/fin-Vion: identical test_reward 1.0, baseline_val 0.0, 3 iterations.

CHANGELOG release-notes slice, against PR #184's actual CHANGELOG:

lines:     119
link defs leaked: 0

compileall over core/cap_evolve + core/tests: clean, exit 0.

Files touched

Packaging: core/pyproject.toml, core/MANIFEST.in (new),
core/cap_evolve/_bundled/{skills,templates} (new symlinks).
Code: core/cap_evolve/resources.py (new), core/cap_evolve/harness.py,
core/cap_evolve/cli.py, core/cap_evolve/_bootstrap.py.
Tests: core/tests/test_packaging.py (new).
Release: .github/workflows/release.yml (new), .github/workflows/ci.yml (comments).
Metadata/docs: CITATION.cff, CHANGELOG.md, core/README.md (the page PyPI renders),
README.md, docs/INSTALL.md, docs/GETTING_STARTED.md, docs/TROUBLESHOOTING.md,
site/getting-started.html, dashboard/backend/pyproject.toml,
dashboard/backend/README.md, dashboard/backend/capevolve_dashboard/_bootstrap.py.

#125)

Rename the distribution `cap-evolve-core` -> `cap-evolve` (the old name was never
published, so there is nothing to migrate) and make the built artifact actually
runnable, then add the workflow that would publish it.

The core is pure `.py`, but a run also needs data that lives outside it:
`skills/optimizers/registry.yaml`, `skills/_registry/manifest.json`, every skill's
`scripts/run.py`, and `templates/project/`. A wheel built from the old pyproject
shipped none of it — #193's `install.sh` hole (a stock install that cannot run an
optimizer) in pip shape. Verified as a negative control: such a wheel dies with
`skills dir not found`.

- `cap_evolve/_bundled/{skills,templates}` are SYMLINKS to the repo trees, declared
  as package-data. setuptools follows them into both wheel and sdist, so there is
  one copy and the package can never drift from the repo.
- New `cap_evolve.resources.resource_root()` returns the repo root or the packaged
  bundle, whichever is real. It replaces six `parents[2]` repo-layout assumptions in
  `harness.py` and the CLI's `_find_skills_dir()` parent-walk, which worked only
  from a checkout.
- Version single-sourced in `cap_evolve.__version__`; pyproject reads it via
  `[tool.setuptools.dynamic]`, so there is no second literal to forget.
- `CITATION.cff` date-released 2026-06-14 -> 2026-07-27, the v0.1.0 GitHub release
  date (#186). Machine-read metadata that was six weeks stale.
- `MANIFEST.in` + exclude-package-data keep a local `__pycache__` out of the
  artifacts, so a dev's build matches CI's.

`.github/workflows/release.yml` builds sdist+wheel, `twine check`s them, asserts the
required data files are inside the artifacts, then installs EACH artifact into a
clean venv and runs a real zero-API optimization to a sealed test number from a temp
dir outside the checkout with `HOME` redirected — inside the repo the skills-dir
fallbacks rescue a broken package and prove nothing. Publish is PyPI Trusted
Publishing (OIDC, no stored token) on a `v*` tag, plus a GitHub Release whose notes
are sliced from the CHANGELOG section for that version.

NOTHING IS PUBLISHED by this change. No tag is pushed, no PyPI/TestPyPI credentials
exist in the repo, and the publish jobs only fire on a `v*` tag or an explicit
manual dispatch.

`core/tests/test_packaging.py` guards the version single-sourcing, the CITATION
agreement, the data-file declarations, the symlink invariant, and that no
`parents[2]` repo-walk creeps back into the core.

Closes #125
Copilot AI review requested due to automatic review settings July 30, 2026 23:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command and its literal output. Python: /tmp/ce-venv/bin/python (3.14), build 1.5.0, twine 7.0.0.

0. Baseline test suite on origin/main (before any change)

$ cd /tmp/wt-125 && PYTHONPATH=/tmp/wt-125/core /tmp/ce-venv/bin/python -m pytest core/tests -q
........................................................................ [ 40%]
........................................................................ [ 80%]
...................................                                      [100%]
179 passed in 64.24s (0:01:04)

1. No PyPI/TestPyPI credentials exist — so no upload dry-run was possible

$ gh secret list --repo skillberry-ai/cap-evolve
ANTHROPIC_AUTH_TOKEN	2026-07-26T11:24:58Z
ANTHROPIC_BASE_URL	2026-07-13T13:11:41Z
BOBSHELL_API_KEY	2026-07-06T06:47:05Z
SKILLBERRY_BOT_TOKEN	2026-07-02T15:12:07Z

$ gh api repos/skillberry-ai/cap-evolve/environments | head -c 300
{"total_count":2,"environments":[{"id":17164903783,...,"name":"copilot",...

No PYPI_* / TEST_PYPI_* secret and no pypi/testpypi environment. Per instructions
I created and requested none, so the TestPyPI dry-run was not run.

Neither name is taken yet:

$ curl -s https://pypi.org/pypi/cap-evolve-core/json
{"message": "Not Found"}
$ curl -s -o /dev/null -w "%{http_code}\n" https://pypi.org/pypi/cap-evolve/json
404

2. Build, twine check, artifact contents

$ cd /tmp/wt-125/core && rm -rf dist build && /tmp/ce-venv/bin/python -m build
Successfully built cap_evolve-0.1.0.tar.gz and cap_evolve-0.1.0-py3-none-any.whl

$ /tmp/ce-venv/bin/twine check dist/*
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED

The same assertion the release workflow runs, on both artifacts:

$ /tmp/ce-venv/bin/python - <<'PY'
import glob,tarfile,zipfile
need=["_bundled/skills/optimizers/registry.yaml","_bundled/skills/_registry/manifest.json",
      "_bundled/skills/algorithms/hill-climb/scripts/run.py","_bundled/templates/project/capevolve.yaml"]
whl=zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
sd=tarfile.open(glob.glob("dist/*.tar.gz")[0]).getnames()
for label,names in (("wheel",whl),("sdist",sd)):
    for n in need: assert any(x.endswith(n) for x in names), f"{label} missing {n}"
    bad=[x for x in names if "__pycache__" in x or x.endswith(".pyc")]
    assert not bad, f"{label} detritus {bad[:3]}"
    print(f"{label}: {len(names)} entries, all required data files present, no detritus")
PY
wheel: 247 entries, all required data files present, no detritus
sdist: 383 entries, all required data files present, no detritus

Wheel layout (the 213 data entries are the point of this PR):

    1  cap_evolve-0.1.0.dist-info/METADATA
    1  cap_evolve-0.1.0.dist-info/RECORD
    1  cap_evolve-0.1.0.dist-info/WHEEL
    1  cap_evolve-0.1.0.dist-info/entry_points.txt
    1  cap_evolve-0.1.0.dist-info/top_level.txt
  176  cap_evolve/_bundled/skills
   37  cap_evolve/_bundled/templates
   ... 29 cap_evolve/*.py
--- key files ---
  cap_evolve/_bundled/skills/_registry/manifest.json
  cap_evolve/_bundled/skills/optimizers/registry.yaml

The installed skill script is byte-identical to the repo's (the symlink invariant):

$ diff /tmp/wt-125/skills/algorithms/hill-climb/scripts/run.py \
       $SP/_bundled/skills/algorithms/hill-climb/scripts/run.py && echo IDENTICAL
IDENTICAL

3. __pycache__ leak found and fixed mid-work

The first wheel shipped 8 .pyc files from a local pytest run — not in git, so CI would
never have seen it, but a local python -m build would have published a wheel differing
from CI's:

wheel: pyc 8 total 255
sdist: pyc 15 total 396

After adding core/MANIFEST.in (global-exclude *.pyc / prune **/__pycache__) plus
[tool.setuptools.exclude-package-data]:

wheel: pyc 0 total 247
sdist: pyc 0 total 382

4. NEGATIVE CONTROL — the old packaging shape is unrunnable

Built the wheel the pre-PR pyproject.toml would produce (package-data = [], no
_bundled/), installed it into a clean venv, ran from a temp dir with HOME redirected:

=== NEGATIVE CONTROL: wheel with no bundled data ===
{"error": "skills dir not found; set CAPEVOLVE_SKILLS_DIR or --skills-dir"}

This is the assertion that makes the positive result below meaningful.

5. Why the run must be outside the repo with HOME redirected (#208)

My first attempt ran under my real $HOME and silently picked up a stale
~/.claude/skills instead of the wheel's own copy:

{"step": "algorithm", "returncode": 2, "error": "usage: hill-climb [-h] --run-dir RUN_DIR ...
hill-climb: error: unrecognized arguments: --capabilities system-prompt --optimizer-name mock\n"}

$ grep -c "capabilities" ~/.claude/skills/hill-climb/scripts/run.py
0

A failure that says nothing about the artifact. Every run below sets
HOME=$D/home and unsets CAPEVOLVE_SKILLS_DIR / CAPEVOLVE_CORE / PYTHONPATH.

6. THE CRITICAL TEST — fresh venv from the WHEEL, real run outside the repo

$ V=/tmp/final-whl; rm -rf $V; /tmp/ce-venv/bin/python -m venv $V
$ $V/bin/pip install -q /tmp/wt-125/core/dist/cap_evolve-0.1.0-py3-none-any.whl
$ D=$(mktemp -d /tmp/finw-XXXX); mkdir -p "$D/.capevolve/project/adapters" "$D/home"
$ cp /tmp/wt-125/examples/toy_calc/adapter.py "$D/.capevolve/project/adapters/"
$ cp -R /tmp/wt-125/examples/toy_calc/capability "$D/seed_capability"
$ SP=$($V/bin/python -c "import cap_evolve,pathlib;print(pathlib.Path(cap_evolve.__file__).parent)")
$ cp "$SP/_bundled/templates/project/capevolve.yaml" "$D/.capevolve/project/capevolve.yaml"
$ cd "$D"
### WHEEL: venv=/tmp/final-whl cwd=/tmp/finw-7Tvt (outside repo) ###
$ env -u CAPEVOLVE_SKILLS_DIR -u CAPEVOLVE_CORE -u PYTHONPATH HOME="$D/home" \
    CAPEVOLVE_TOY_DATA=/tmp/wt-125/examples/toy_calc \
    CAPEVOLVE_MOCK_SCRIPT=/tmp/wt-125/examples/toy_calc/mock_script.json \
    $V/bin/cap-evolve run --spec "$D/.capevolve/project/capevolve.yaml" \
      --project "$D/.capevolve/project" --run-ts final --dashboard off
{
  "run_dir": ".capevolve/run_final",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_final/dashboard.html"
}

Note the templates were copied from site-packages, not the repo — the packaged copy is
what the run consumed. check and version from the same wheel:

$ $V/bin/cap-evolve check "$D/.capevolve/project"
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 8 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}
$ $V/bin/cap-evolve version
{"cap-evolve": "0.1.0"}

7. Same test from the SDIST

$ V=/tmp/final-tar.gz; /tmp/ce-venv/bin/python -m venv $V
$ $V/bin/pip install -q /tmp/wt-125/core/dist/cap_evolve-0.1.0.tar.gz
### tar.gz : venv=/tmp/final-tar.gz cwd=/tmp/fin-Vion ###
{
  "run_dir": ".capevolve/run_final",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_final/dashboard.html"
}

8. Release-notes slice — verified against PR #184's actual CHANGELOG

$ git show pr184:CHANGELOG.md > /tmp/cl184.md
$ awk -v v=0.1.0 '$0 ~ "^## \\[" v "\\]" {on=1;next} on && /^## \[/{exit} on' /tmp/cl184.md \
    | sed '/./,$!d' > /tmp/notes.md
$ echo "lines: $(wc -l < /tmp/notes.md)"
lines:      119
$ grep -cE '^\[[^]]+\]: https?://' /tmp/notes.md
0
$ head -8 /tmp/notes.md
Initial release. Tag [`v0.1.0`](https://github.com/skillberry-ai/cap-evolve/releases/tag/v0.1.0)
at commit `1a24604`; `core/pyproject.toml` version `0.1.0`. The date is the GitHub
release's `publishedAt` (`2026-07-27`), not the tag-commit date (`2026-07-26`), because
Keep a Changelog dates the *release*.

### Added
- Honest-eval core (`cap_evolve`): seeded splits with a sealed test set,
  significance gate, multi-trial variance, pass^k + pass@k, bootstrap CIs.

#184 places the link definitions on lines 7-8, ABOVE the first ## [ — so a slice can
never capture them. Confirmed structurally:

$ git show pr184:CHANGELOG.md | grep -n "^## \|^\[.*\]: http"
7:[Unreleased]: https://github.com/skillberry-ai/cap-evolve/compare/v0.1.0...HEAD
8:[0.1.0]: https://github.com/skillberry-ai/cap-evolve/releases/tag/v0.1.0
10:## [Unreleased]
62:## [0.1.0] - 2026-07-27

9. #186 — both dates verified from the source of truth

$ gh release view v0.1.0 --repo skillberry-ai/cap-evolve --json publishedAt,createdAt,tagName
{"createdAt":"2026-07-26T16:05:21Z","publishedAt":"2026-07-27T10:51:37Z","tagName":"v0.1.0"}
$ git log -1 --format='%ai %ci' v0.1.0
2026-07-26 19:05:21 +0300 2026-07-26 19:05:21 +0300

CITATION.cff said 2026-06-14. Now 2026-07-27 (the release date, matching #101's
reasoning), guarded by test_citation_version_and_date_match_the_release.

10. Canonical counts (#102 / PR #189) recomputed on this branch

core/README.md is what PyPI renders, so its numbers must match #189's guard. Recomputed
from the filesystem using #189's own counting rule:

$ /tmp/ce-venv/bin/python -c "<#189's counting rule>"
skills 20
algos 5
agent-only 2 ['agent-optimize', 'evograph'] exec 3
optimizers 14

core/README.md states "20 Agent Skills ... 5 algorithms — 3 run-executable + 2
agent-mode — ... 14 optimizer backends". Match.

11. Full test suite — 187 passed, 0 failed

$ cd /tmp/wt-125 && PYTHONPATH=/tmp/wt-125/core /tmp/ce-venv/bin/python -m pytest core/tests -q
........................................................................ [ 38%]
........................................................................ [ 77%]
...........................................                              [100%]
187 passed in 65.40s (0:01:05)

179 baseline + 8 new in core/tests/test_packaging.py:

$ PYTHONPATH=/tmp/wt-125/core /tmp/ce-venv/bin/python -m pytest core/tests/test_packaging.py -q
........                                                                 [100%]
8 passed in 0.07s

test_dashboard_launch.py (flaky on port 7878, #200) passed in this run.

12. compileall

$ /tmp/ce-venv/bin/python -m compileall -q core/cap_evolve core/tests
COMPILEALL CLEAN (exit 0)

13. Confirmation

No twine upload, no gh workflow run, no tag pushed, no credential created or
requested. The only network calls were two read-only GETs to pypi.org/pypi/...json
(both 404) and pip install from local file paths.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #250

CHANGES REQUESTED

The artifact itself is good: I built both, installed both into clean venvs, and both completed a real run from outside the checkout at test_reward 1.0, and the negative control fails exactly as claimed. Every one of the 213 data files is byte-identical to the repo in both artifacts. The packaging is right.

The release path is not. Two defects, both on the irreversible side of the tag push.

Blocking

1. .github/workflows/release.yml:191-193 — the CHANGELOG gate runs after PyPI publish, so pushing v0.1.0 today publishes an unyankable-to-fix artifact and then fails.

The job graph:

build  →  verify  →  publish-pypi  →  github-release
                     (IRREVERSIBLE)   (contains the CHANGELOG slice gate)

The Slice release notes from CHANGELOG step — including [ -s notes.md ] || exit 1 — lives in github-release, which needs: publish-pypi. And on this branch the CHANGELOG has no [0.1.0] section at all (#184 carries it; see Merge-order note):

$ grep -n "^## \[" CHANGELOG.md
7:## [Unreleased]

$ v=0.1.0; awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {on=1;next} on && /^## \[/{exit} on' CHANGELOG.md | sed '/./,$!d' > /tmp/n.md
$ [ -s /tmp/n.md ] || echo "::error::no CHANGELOG section for [$v]  -> github-release JOB FAILS"
::error::no CHANGELOG section for [0.1.0]  -> github-release JOB FAILS

Consequence: someone follows the runbook, pushes v0.1.0, build and verify go green, cap-evolve 0.1.0 lands on PyPI, then github-release red-fails on the missing section. The version can now only be yanked, never replaced. The runbook's step 5 asks a human to check this by hand — that is precisely the check a gate exists to not depend on.

Fix: make the notes slice a precondition, not a postcondition. Move the step into build (it only needs CHANGELOG.md from the checkout, which build already has — add actions/checkout output or upload notes.md as an artifact) and have publish-pypi depend on it. One-line version: add the slice+[ -s ] assertion to the build job and keep the gh release create step where it is.

2. core/pyproject.toml:36dashboard = ["capevolve-dashboard"] names a distribution that does not exist on PyPI and that nothing in this PR publishes, so the install line on the PyPI landing page fails on day one.

$ curl -s -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/capevolve-dashboard/json
404
$ /tmp/rv250-dash/bin/pip install --index-url https://pypi.org/simple \
    "/tmp/rv-250/core/dist/cap_evolve-0.1.0-py3-none-any.whl[dashboard]"
ERROR: Could not find a version that satisfies the requirement capevolve-dashboard; extra == "dashboard" (from cap-evolve[dashboard]) (from versions: none)
ERROR: No matching distribution found for capevolve-dashboard; extra == "dashboard"

core/README.md:29 documents pip install 'cap-evolve[dashboard]' — and core/README.md is the PyPI long description. release.yml builds only core/; there is no workflow path that ever publishes capevolve-dashboard. So the first thing a user reads on the project page is a command that cannot resolve.

Note the metadata is already baked into the built wheel, so this is not fixable post-publish either:

$ Requires-Dist: capevolve-dashboard; extra == "dashboard"

Fix, pick one: (a) drop the [dashboard] extra to dashboard = [] for 0.1.0 and change core/README.md:29 to pip install ./dashboard/backend from a clone; or (b) extend release.yml to build and publish capevolve-dashboard in the same tag run, ordered so cap-evolve publishes second. (a) is the smaller change and matches "0.1.0 ships the core"; the live dashboard is optional and a run already writes a standalone dashboard.html without it (verified below).

Non-blocking

3. core/cap_evolve/_bundled/skills, :templates — a build from a checkout without symlink support silently produces a data-free wheel that passes twine check. Reproduced with git -c core.symlinks=false, which is the Windows default outside Developer Mode:

$ file core/cap_evolve/_bundled/skills
core/cap_evolve/_bundled/skills: ASCII text, with no line terminators
$ cat core/cap_evolve/_bundled/skills
../../../skills

$ python -m build && python -c "...zipfile..."
BUNDLED ENTRIES IN NO-SYMLINK WHEEL: 0

$ twine check dist/*
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED
$ /tmp/rv250-winv/bin/cap-evolve version
{"cap-evolve": "0.1.0"}

The symlink becomes a 15-byte text file, setuptools globs nothing, and you get a wheel that installs and answers version while being unrunnable. Not blocking because release.yml builds on ubuntu-latest and the Artifact contains the runtime data files step (release.yml:79-97) does catch it — that step is load-bearing, which is worth saying out loud in its comment. Suggest a one-line guard in the build job before python -m build: test -L core/cap_evolve/_bundled/skills || { echo "::error::_bundled/skills is not a symlink — checkout lost symlink support"; exit 1; }, so the failure names the cause instead of surfacing as "missing data file".

4. core/cap_evolve/resources.py:33CAPEVOLVE_RESOURCE_ROOT is a new public escape hatch with zero documentation. git grep CAPEVOLVE_RESOURCE_ROOT outside core/cap_evolve/ and core/tests/ returns nothing — it is not in docs/INSTALL.md, docs/TROUBLESHOOTING.md, or RUN.md, while the four sibling CAPEVOLVE_* vars are. It is also the only override that can rescue the #208 stale-~/.claude/skills trap, which makes it exactly the var a confused user needs to find. Add a line to docs/TROUBLESHOOTING.md.

5. core/README.md:21-23 — the counts on the PyPI page are outside #189's guard. The numbers are correct today; I recomputed with #189's own rule on this branch:

N_SKILLS 20 N_ALGOS 5 AGENT_ONLY ['agent-optimize', 'evograph'] N_EXEC 3 N_OPT 14
core/README.md claims: 20 skills, 5 algos (3 exec + 2 agent), 14 optimizers
MATCH: True

But grep -c '"core/README.md"' <#189's test>0. #189's CLAIMS covers README.md, llms.txt, CHANGELOG.md, docs/ARCHITECTURE.md, site/index.html, site/architecture.html — not core/README.md. That is the one surface where a stale count is published rather than merely wrong on a site you can edit. Add three CLAIMS rows for core/README.md (anchored on \*\*(\d+) Agent, \*\*(\d+) algorithms\*\*, \*\*(\d+) optimizer backends\*\*) when #189 lands.

6. #214 added a documented-CLI checker; there is no equivalent for documented pip install lines, and this PR is the reason to want one. A checker is warranted, narrowly: assert that every pip install <name> / pip install '<name>[extra]' in tracked Markdown/HTML names either a path, a distribution this repo builds, or one that resolves on PyPI. Finding 2 is exactly what it would have caught, in the one file that becomes a public landing page. Not worth a general link-checker; a 15-line test over core/README.md + docs/INSTALL.md covers the surfaces that matter.

Nits

7. docs/superpowers/plans/2026-06-18-cap-evolve-dashboard-backend.md:9,73,93,1061 still say cap-evolve-core. Historical plan document, so arguably correct-as-written; flagging only for completeness since it is the sole remaining occurrence outside a deliberate one.

8. .github/workflows/release.yml:105 uploads the artifact as bare dist, and publish-pypi downloads it with path: dist — so if capevolve-dashboard is ever added to this workflow (fix 2b) the two dists collide in one directory. Name it dist-core now while it is free.

Does the artifact work?

Yes — both, independently reproduced. Neither run had PYTHONPATH, CAPEVOLVE_CORE, CAPEVOLVE_SKILLS_DIR, or CAPEVOLVE_RESOURCE_ROOT set; HOME was redirected to a fresh empty dir; cwd was a mktemp -d outside the checkout; the capevolve.yaml was copied from site-packages, not from the repo.

### whl: venv=/tmp/rv250-whl cwd=/tmp/rv250run-whl-uuHO sitepkgs=/private/tmp/rv250-whl/lib/python3.14/site-packages/cap_evolve ###
version 0.1.0
{
  "run_dir": ".capevolve/run_rv",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": { "1": 1.0, "2": 0.0 },
  "iterations": 3,
  "dashboard": ".capevolve/run_rv/dashboard.html"
}
dashboard.html: 35243 bytes

### tar.gz: venv=/tmp/rv250-tar.gz cwd=/tmp/rv250run-tar.gz-jMNS ###
version 0.1.0
{
  ... "test_reward": 1.0, "test_delta": 1.0, "iterations": 3 ...
}
dashboard.html: 35242 bytes

Both wrote a standalone dashboard.html with no [dashboard] extra installed — so finding 2 costs the live dashboard only, not the run's own output. That is what keeps it a "change the doc" fix rather than a design problem.

Negative control — fails exactly as claimed. I rebuilt the pre-PR packaging shape from origin/main:core/pyproject.toml (name = "cap-evolve-core", no package-data, _bundled/ removed), installed the wheel, ran the same way:

$ ls .../site-packages/cap_evolve/_bundled
ls: .../site-packages/cap_evolve/_bundled: No such file or directory

=== NEGATIVE CONTROL: pre-PR wheel, run outside repo, HOME redirected ===
{"error": "skills dir not found; set CAPEVOLVE_SKILLS_DIR or --skills-dir"}
exit=1

Same command, same env, same cwd shape — only the packaging differs. The positive result means something.

Symlinked package-data audit

Real bytes in both artifacts, zero symlinks survive into either. setuptools dereferences on the way in, for wheel and sdist:

wheel entries 247   sdist entries 383
wheel pyc []        sdist pyc []
wheel zero-byte entries: 0 []
sdist symlink/hardlink members: []
sdist zero-byte files: 0 []

And byte-compared, not name-compared — all 213 data files against the repo, in both artifacts:

git data files: 213 wheel data: 213 sdist data: 309
MISSING FROM WHEEL: none
MISSING FROM SDIST: none
EXTRA IN WHEEL: none
EXTRA IN SDIST: [... directory entries only ...]
BYTE MISMATCHES: none (all 213 x2 byte-identical to repo)

(The sdist's 309 vs 213 is tar directory members; the extras list is entirely directories.)

Post-install trees, both venvs:

=== any symlinks in installed wheel tree? ===   count: 0
=== broken links? ===                            (none)
=== zero-byte files? ===                         count: 0
    9355 .../cap_evolve/_bundled/skills/optimizers/registry.yaml
   13615 .../cap_evolve/_bundled/skills/_registry/manifest.json
    6495 .../cap_evolve/_bundled/templates/project/capevolve.yaml

$ diff -r -x '__pycache__' skills <wheel install>/_bundled/skills   → WHEEL skills IDENTICAL
$ diff -r -x '__pycache__' templates <wheel install>/_bundled/templates → WHEEL templates IDENTICAL
$ diff -r -x '__pycache__' skills <sdist install>/_bundled/skills   → SDIST skills IDENTICAL
$ diff -r -x '__pycache__' templates <sdist install>/_bundled/templates → SDIST templates IDENTICAL

(The __pycache__ dirs in the installed tree are pip's own byte-compilation of the installed .py files, not shipped detritus — the artifacts themselves contain zero .pyc, confirming the MANIFEST.in fix.)

Case-insensitive filesystem: no risk found — no two paths under skills/ or templates/ collide case-insensitively, and the install trees above were produced on APFS (case-insensitive) with byte-exact results.

Windows / no-symlink checkout: real but bounded — see finding 3. The link degrades to a text file and the wheel ships zero data while passing twine check. CI builds on Linux and the build job's data-file assertion catches it, so this cannot reach PyPI through the documented path; it can reach a developer's local python -m build.

Completeness

Derived from what a run actually opens: the 7 resource_root() consumers in harness.py (:1036, :1069, :1485, :1574, :1639, :1679) plus cli.py:43, then the whole skills/ + templates/ closure, then byte-compared. resource_root() has replaced every parents[2] in the core — grep -rn "parents\[" core/cap_evolve/ returns exactly one hit, resources.py:23, the module that is allowed to know.

Runtime-needed file In wheel? In sdist?
skills/_registry/manifest.json (cli.py:89) ✅ 13615 B, byte-identical ✅ byte-identical
skills/optimizers/registry.yaml (harness.py:1640) ✅ 9355 B, byte-identical ✅ byte-identical
templates/project/capevolve.yaml ✅ 6495 B, byte-identical ✅ byte-identical
templates/project/optimizer/INSTRUCTIONS.md (harness.py:1574)
skills/algorithms/*/scripts/run.py (all 5)
skills/capabilities/*/scripts/abstract.py (harness.py:1679)
skills/phases/diagnose/** (harness.py:1069)
all skills/**/SKILL.md, meta.yaml, references/** (74 md, 29 yaml)
all templates/adapters/**, templates/skill/**
totals 213/213 213/213

Reverse direction — nothing shipped that shouldn't be: EXTRA IN WHEEL: none, EXTRA IN SDIST: <directories only>, zero .pyc, zero __pycache__, no core/tests/ in the wheel, no dashboard/frontend/dist/ (the ~950 KB exclusion holds).

Name change

cap-evolve-core is genuinely unpublished (404), as is cap-evolve — so there is nothing to migrate and nothing to squat. Full tracked-file sweep:

$ curl -s -o /dev/null -w '%{http_code}' .../cap-evolve-core/json   → 404
$ curl -s -o /dev/null -w '%{http_code}' .../cap-evolve/json        → 404

Every reference updated: .github/workflows/ci.yml:7,38,78, README.md:63, docs/GETTING_STARTED.md:19, docs/INSTALL.md:31,42, docs/TROUBLESHOOTING.md:13, site/getting-started.html:75, dashboard/backend/README.md:6, dashboard/backend/pyproject.toml:11, core/cap_evolve/_bootstrap.py:10, core/README.md (retitled, badges repointed, plus an explicit ## Naming note). No stale pip install cap-evolve-core anywhere.

Remaining occurrences are all deliberate or historical: CHANGELOG.md:29 (documenting the rename), core/README.md:51 (the ## Naming explanation), and docs/superpowers/plans/2026-06-18-*.md (nit 7, a dated plan record). No stale install instruction left.

The one broken documented install line is finding 2 — and it is a new name, not a stale one.

Release-path safety

The tag-mismatch gate fires. Simulated both directions with the workflow's own code:

=== agreeing tag ===
package=0.1.0 tag=0.1.0
GATE PASSES (correct)

=== DISAGREEING tag v0.2.0 ===
package=0.1.0 tag=0.2.0
::error::__version__ 0.1.0 != tag 0.2.0
exit=1

The dynamic version also resolves in the built artifact, not just in-tree:

$ python -c "...METADATA..."
['Name: cap-evolve', 'Version: 0.1.0', 'Provides-Extra: judge', 'Provides-Extra: dashboard',
 'Requires-Dist: capevolve-dashboard; extra == "dashboard"', 'Provides-Extra: dev', ...]
$ /tmp/rv250-whl/bin/python -c "import importlib.metadata as m; ..."
version 0.1.0
name cap-evolve

And the guard against a second literal actually bites — I added version = "9.9.9" under the dynamic line and it failed, then restored:

FAILED core/tests/test_packaging.py::test_version_is_single_sourced - AssertionError
1 failed in 0.03s

Can host state fake a green build? For the artifact check, no — and this is the part of the PR I'd defend hardest. release.yml:150-155 runs env -u CAPEVOLVE_SKILLS_DIR -u CAPEVOLVE_CORE -u PYTHONPATH HOME="$D/home" with cd "$D" where $D is a fresh $RUNNER_TEMP/outside. That closes all four fallbacks in cli.py:31-44 in order: $CAPEVOLVE_SKILLS_DIR unset, ./.claude/skills absent (fresh cwd), ~/.claude/skills and ~/.capevolve/skills absent (redirected HOME). Only the installed package can supply skills/. It also checks out the repo to path: repo so the checkout supplies toy data but never sits above the venv. This is a correct fix for the #208 trap the author actually hit, and the run asserts test_reward == 1.0 rather than exit code — so a degraded run cannot pass either. CAPEVOLVE_RESOURCE_ROOT is not in the env -u list, but it is a new var that no runner would have set; add it for symmetry.

The gate that does not protect anything is the CHANGELOG one — finding 1. Host state can't fake a green build; job ordering can let a red build publish anyway. That is the same class of hole, one layer up.

#208 itself stays open: install.sh is still not exercised by any workflow (grep -n install.sh .github/workflows/*.yml hits only a comment in release.yml:12). Correctly out of scope here.

Merge-order note

#189#184#250. Not the order the PR assumes.

#186 is correctly closed by this PR: the fix is two lines in CITATION.cff plus the guard in test_packaging.py, and splitting a two-line metadata fix into its own PR would be ceremony. I verified the date against the source of truth rather than the PR's quote of it — gh release view v0.1.0 --json publishedAt2026-07-27T10:51:37Z, tag commit 2026-07-26. date-released: 2026-07-27 is right, and dating the release rather than the tag commit matches CFF semantics and #101's reasoning.

Verification I re-ran

Full suite, 187 (179 + 8), test_dashboard_launch.py (#200) passed this run:

$ cd /tmp/rv-250 && PYTHONPATH=/tmp/rv-250/core /tmp/ce-venv/bin/python -m pytest core/tests -q
........................................................................ [ 38%]
........................................................................ [ 77%]
...........................................                              [100%]
187 passed in 62.87s (0:01:02)
$ /tmp/ce-venv/bin/python -m compileall -q core/cap_evolve core/tests
COMPILEALL CLEAN (exit 0)
$ cd /tmp/rv-250/core && rm -rf dist build && python -m build
Successfully built cap_evolve-0.1.0.tar.gz and cap_evolve-0.1.0-py3-none-any.whl

$ twine check dist/*
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED

Repo-checkout mode is unaffected by the resource_root refactor. resource_root() prefers the repo when both exist (resources.py:35 tests _SOURCE / "skills" before _BUNDLED), so a developer editing skills/ gets their edits, not the bundle:

$ PYTHONPATH=/tmp/rv-250/core python -c "from cap_evolve.resources import resource_root; print(resource_root())"
root: /private/tmp/rv-250

And I ran the toy example from an identical harness against this branch and against origin/main and diffed:

$ diff /tmp/out-rv-250.json /tmp/out-rv-main.json && echo "IDENTICAL repo-checkout result"
IDENTICAL repo-checkout result

PR verification claims: all reproduced. 247/383 artifact entries, 0 .pyc, twine check PASSED on both, 187 tests, the 119-line/0-link CHANGELOG slice, the 2026-07-27 date, the 20 · 5 (3+2) · 14 counts, both fresh-venv runs at test_reward 1.0, and the negative control. Nothing in the evidence section was overstated. The gap is not in what was tested — it is that the CHANGELOG gate was verified as a slice and never as a gate in its job position, and that [dashboard] was reasoned about but never installed.

Test quality of the 8 new tests: they assert declarations, not artifact contents — 'dynamic = ["version"]' in PYPROJECT, pattern in PYPROJECT, link.is_symlink(). The docstring is honest that the artifact is asserted in release.yml instead, and that split is defensible offline. Two are genuinely behavioural and earn their place: test_resource_root_prefers_a_bundled_tree_when_there_is_no_repo and test_no_parents_2_repo_walk_left_in_the_core (that one is a real regression fence — it would catch a future parents[2] creeping back). test_runtime_data_is_declared_as_package_data is the weakest: substring-matching a glob pattern in TOML text passes even if the glob matches nothing. The release.yml artifact assertion is what actually covers it, which again makes that step load-bearing.

Release runbook (## What a human must do to release): commands are correct and in the right order, and step 6 is unambiguously marked as the point of no return with the yank-not-replace caveat. Step 5 asks the human to verify the CHANGELOG section by hand — finding 1 is that this must be a gate, not a step in prose.

@skillberry-bot

Copy link
Copy Markdown
Contributor

Automatic Labeling Failed

An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually.

… the unresolvable [dashboard] extra

Review fixes for #250.

BLOCKING 1 — the workflow published, then failed its own gate. The CHANGELOG slice
lived in `github-release` (`needs: publish-pypi`), so pushing `v0.1.0` today would
upload to PyPI (irreversible) and only then fail, because this branch has no
`[0.1.0]` section. All gates now run in `build`/`verify`: tag/version match, a
`_bundled` symlink precondition, the CHANGELOG slice, `twine check`, the
artifact-contents assertion, and the install-and-run. `publish-pypi` holds nothing
but the upload; `github-release` validates nothing and consumes the notes artifact
`build` proved non-empty. This also removes the dependency on #184 landing first —
a premature tag fails in `build`, uploading nothing. Artifact `dist` -> `dist-core`
so a second distribution cannot collide, and `CAPEVOLVE_RESOURCE_ROOT` joins the
`env -u` list for symmetry.

BLOCKING 2 — the PyPI long description documented an install that cannot resolve.
`dashboard = ["capevolve-dashboard"]` named a distribution that is 404 on PyPI and
that no workflow publishes, and `core/README.md` (the rendered PyPI page) told users
to run `pip install 'cap-evolve[dashboard]'`. Extras bake into the wheel METADATA,
so this was unfixable post-publish. Dropped the extra rather than take on a second
distribution: every run already writes a standalone `dashboard.html` with nothing
extra installed, so this is a docs/metadata gap, not a feature gap. README now
points at the from-clone install. New `test_documented_pip_installs_can_resolve`
asserts every documented `pip install` on the two published surfaces names a
declared extra, and `test_pypi_page_counts_match_the_repo` guards the counts on the
one page where a stale count is published rather than merely wrong.

Windows-symlink silent pass: on a `core.symlinks=false` checkout the `_bundled`
symlinks degrade to 15-byte text files, the package-data globs match nothing, and
`python -m build` emits a data-free wheel that `twine check` PASSES. Kept the
symlinks (they are what stops the packaged trees drifting from the repo) and made
the failure loud instead: a pre-build symlink assertion, an artifact-assertion
failure message that names this cause, and the constraint documented in
pyproject.toml, resources.py, and docs/TROUBLESHOOTING.md.

Also documents `CAPEVOLVE_RESOURCE_ROOT` in docs/TROUBLESHOOTING.md, including the
#208 stale-`~/.claude/skills` trap it is the escape hatch for.
Copilot AI review requested due to automatic review settings July 31, 2026 00:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

Commit 4d3b95d7. Both blocking findings fixed; 6 non-blocking/nits addressed or declined below. Nothing was published — no twine upload, no tag push, no gh workflow run.


Gate ordering

Job order on a v* tag push is now build → verify → publish-pypi → github-release. Derived mechanically from the workflow's own needs: graph, then every step whose run: can fail (exit 1 / assert / ::error::) classified by job index against publish-pypi:

Gate Job Runs before publish?
Version agrees with the tag build
_bundled symlinks survived the checkout (new) build
CHANGELOG has a section for the tag (moved out of github-release) build
twine check build
Artifact contains the runtime data files build
Zero-API run outside the repo (install-and-run) verify
— upload to PyPI — publish-pypi the irreversible step
Create the release (consumes the pre-proved notes artifact; validates nothing) github-release n/a
JOB ORDER on a v* tag push: build -> verify -> publish-pypi -> github-release

gate                                                 job              before publish?
Version agrees with the tag                          build            YES
_bundled symlinks survived the checkout              build            YES
CHANGELOG has a section for the tag                  build            YES
Artifact contains the runtime data files             build            YES
Zero-API run outside the repo                        verify           YES

GATES AFTER PUBLISH: NONE

publish-pypi now contains only download-artifact + gh-action-pypi-publish. github-release slices nothing: build writes notes.md, asserts it non-empty, and uploads it as a release-notes artifact that github-release downloads. Nothing downstream of the upload can fail on something a pre-publish gate could have caught.

Simulation, not reading

release.yml's steps executed locally in job order against a clone, stopping at the first failure. Steps needing network or a built artifact are skipped and named (each lives in build/verify, i.e. still pre-publish); every gate that can run offline runs verbatim from the YAML.

1. Missing [0.1.0] section — this branch's actual state, the reviewer's scenario:

=== SCENARIO: CHANGELOG missing [0.1.0] (tag v0.1.0) ===
  [build] Install build tooling: (skipped here — proven separately; job index 0 < publish 2)
  [build] Version agrees with the tag: exit=0
      package=0.1.0 tag=0.1.0
  [build] _bundled symlinks survived the checkout: exit=0
      _bundled/skills and _bundled/templates are live symlinks
  [build] CHANGELOG has a section for the tag: exit=1
      ::error::no CHANGELOG section for [0.1.0] — add one before tagging
  RESULT: failed at [build] 'CHANGELOG has a section for the tag' -> BEFORE publish

2. Tag mismatch:

=== SCENARIO: tag v0.2.0 vs __version__ 0.1.0 (tag v0.2.0) ===
  [build] Version agrees with the tag: exit=1
      package=0.1.0 tag=0.2.0
      ::error::__version__ 0.1.0 != tag 0.2.0
  RESULT: failed at [build] 'Version agrees with the tag' -> BEFORE publish

3. Artifact missing its data files — induced the real cause (a core.symlinks=false checkout), CHANGELOG section present so it can't fail earlier:

=== SCENARIO: core.symlinks=false (symlink is a 15-byte text file) (tag v0.1.0) ===
  [build] Version agrees with the tag: exit=0
      package=0.1.0 tag=0.1.0
  [build] _bundled symlinks survived the checkout: exit=1
      ::error::core/cap_evolve/_bundled/skills is not a symlink — this checkout lost symlink
      support (git config core.symlinks=false, the Windows default). The build would emit a
      data-free wheel that passes twine check. Re-clone with symlinks enabled.
  RESULT: failed at [build] '_bundled symlinks survived the checkout' -> BEFORE publish

And the artifact assertion itself, run on that data-free wheel (which twine check passes):

=== build from a core.symlinks=false-shaped checkout ===
-rw-r--r--  15 core/cap_evolve/_bundled/skills        # 15-byte text file, not a symlink
Successfully built cap_evolve-0.1.0.tar.gz and cap_evolve-0.1.0-py3-none-any.whl
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED

=== the artifact assertion on THAT wheel ===
wheel entries: 34 | _bundled data entries: 0
AssertionError: wheel missing _bundled/skills/optimizers/registry.yaml — this step is
load-bearing: it is the only check that reads the ARTIFACT rather than the checkout. Most
likely cause: cap_evolve/_bundled/{skills,templates} were not live symlinks at build time
(a core.symlinks=false checkout turns them into 15-byte text files, the package-data globs
match nothing, and twine check still PASSES the empty wheel).

4. Control — CHANGELOG section present (post-#184 shape): all runnable gates pass, and only then is the upload reached.

  [build] Version agrees with the tag: exit=0
  [build] _bundled symlinks survived the checkout: exit=0
  [build] CHANGELOG has a section for the tag: exit=0
  [build] Build / twine check / Artifact contains the runtime data files: (skipped, job index 0 < publish 2)
  [verify] Install into a clean venv / Zero-API run outside the repo: (skipped, job index 1 < publish 2)
  !!! REACHED IRREVERSIBLE UPLOAD (publish-pypi) !!!

#184 dependency removed. The CHANGELOG gate is in build, so a premature tag fails there and uploads nothing. Merge order for safety no longer matters; #184 is still needed for the release itself to succeed (you cannot ship a release with no notes), which is now a loud pre-publish failure rather than a post-publish one. Merge order is otherwise still #189#184#250 for content reasons.


[dashboard] decision: dropped the extra, corrected the README

Chose not to publish a second distribution. capevolve-dashboard is 404 on PyPI, no workflow publishes it, and — as you noted — a run already writes a full standalone dashboard.html with no extra installed. That makes this a docs/metadata defect, not a feature gap, and publishing a second dist to make a doc line true is the wrong direction. core/pyproject.toml keeps a comment saying exactly when to re-add it.

capevolve-dashboard still 404:

capevolve-dashboard on PyPI: 404

Gone from the baked METADATA (before: Provides-Extra: dashboard + Requires-Dist: capevolve-dashboard; extra == "dashboard"):

Name: cap-evolve
Version: 0.1.0
Provides-Extra: judge
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"

Installed from the artifact — the extra is now honestly absent rather than present-and-broken:

### whl: {"cap-evolve": "0.1.0"}
  [dashboard] extra: WARNING: cap-evolve 0.1.0 does not provide the extra 'dashboard'
### tar.gz: {"cap-evolve": "0.1.0"}
  [dashboard] extra: WARNING: cap-evolve 0.1.0 does not provide the extra 'dashboard'

No longer documented anywhere — the only surviving matches are the pyproject comment explaining the removal:

$ git grep -n "cap-evolve\[dashboard\]" -- '*.md' '*.html' '*.toml'
core/pyproject.toml:37:# NO `dashboard` extra. It was `dashboard = ["capevolve-dashboard"]`, naming a
core/pyproject.toml:39:# `pip install 'cap-evolve[dashboard]'` could not resolve, and extras are baked into the

And it stays that way — re-adding the doc line without declaring the extra fails (finding 6's checker):

$ # append pip install 'cap-evolve[dashboard]' to core/README.md
FAILED core/tests/test_packaging.py::test_documented_pip_installs_can_resolve
core/README.md documents `pip install 'cap-evolve[dashboard]'` but pyproject declares no
[dashboard] extra (declared: ['dev', 'judge']) — that install cannot resolve

Windows-symlink silent pass

Kept the symlinks — they are the whole reason the packaged trees cannot drift from the repo, and a copying build hook trades a loud failure for a silent drift risk. Instead made the silent pass impossible to stay silent:

  1. Pre-build gate in build — asserts both links are symlinks and resolve, naming core.symlinks=false and the Windows default in the error (simulation 3 above).
  2. The artifact assertion's failure message now names this cause and says out loud that the step is load-bearing (it is the only check that reads the artifact rather than the checkout).
  3. Documented next to the symlinkscore/pyproject.toml's [tool.setuptools.package-data] comment, core/cap_evolve/resources.py's module docstring, and a new docs/TROUBLESHOOTING.md entry with the test -L check and the core.symlinks=true re-clone.

Response to all 8 findings

  1. Workflow publishes then fails its own gate — FIXED. All gates moved into build/verify; publish-pypi is upload-only; github-release consumes a pre-proved release-notes artifact and validates nothing. Verified by simulation in job order, three failure scenarios plus a control. docs(changelog): correct stale 0.46→0.80, add real [0.1.0] section, move features to Added #184 dependency for safety removed.
  2. [dashboard] documents an unresolvable install — FIXED. Extra dropped, core/README.md corrected to the from-clone path, METADATA clean, guarded by a new test. Justification above.
  3. core.symlinks=false silent data-free wheel — FIXED beyond the suggestion: your one-line guard, plus the artifact assertion's message naming the cause, plus the constraint documented in three places.
  4. CAPEVOLVE_RESOURCE_ROOT undocumented — FIXED. New docs/TROUBLESHOOTING.md entry covering both a broken bundled install and the CI never runs ./install.sh — the documented host-agnostic install path is untested (hid a total optimizer failure) #208 stale-~/.claude/skills trap, with the copy-paste export, a resource_root() confirmation command, the "ignored unless the path really contains skills/" behaviour, and the distinction from CAPEVOLVE_SKILLS_DIR (skills only vs skills and templates). Cross-referenced from resources.py's docstring.
  5. core/README.md counts outside docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189's guard — FIXED here rather than deferred. docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189 isn't on this branch, and this is the one file where a stale count is published, so test_pypi_page_counts_match_the_repo computes all three from the filesystem (skills/*/*/SKILL.md, skills/algorithms/*/, registry.yaml keys) and asserts against the README's **N Agent Skills** / **N algorithms** / **N optimizer backends** — including a failure if the wording changes so the guard cannot silently stop matching. Left the 3-exec + 2-agent split to docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189's own rule; when docs: unify skill count at 20 and algorithm count at 5 across all surfaces (closes #102) #189 lands its CLAIMS rows can absorb this or leave it, both are correct.
  6. No checker for documented pip install lines — DONE, narrowly as suggested. test_documented_pip_installs_can_resolve over core/README.md + docs/INSTALL.md: every pip install '<dist>[<extra>]' must name this dist and a declared extra, and every bare pip install <name> must be cap-evolve (paths and -e forms untouched). It fails on the exact pre-fix state — that is finding 2 caught mechanically.
  7. docs/superpowers/plans/2026-06-18-*.md says cap-evolve-core — DECLINED, as you allowed. It is a dated plan record; rewriting history to match a later rename makes the record less accurate, not more. The name is explained in core/README.md's ## Naming section for anyone who lands there.
  8. Bare dist artifact name — FIXED. distdist-core at the upload and all four downloads. The new release-notes artifact is separately named for the same reason.

Verification

$ cd /tmp/fx-250 && PYTHONPATH=/tmp/fx-250/core python -m pytest core/tests -q
........................................................................ [ 38%]
........................................................................ [ 76%]
.............................................                            [100%]
189 passed in 63.75s (0:01:03)

187 → 189, 0 failed (the 2 new packaging guards). test_packaging.py alone: 10 passed.

$ python -m compileall -q core/cap_evolve core/tests
COMPILEALL CLEAN (exit 0)
$ cd core && rm -rf dist build && python -m build
Successfully built cap_evolve-0.1.0.tar.gz and cap_evolve-0.1.0-py3-none-any.whl
$ twine check dist/*
Checking dist/cap_evolve-0.1.0-py3-none-any.whl: PASSED
Checking dist/cap_evolve-0.1.0.tar.gz: PASSED

Artifact assertion, run verbatim from the workflow:

wheel: 247 entries, all required data files present
sdist: 383 entries, all required data files present

213/213 data files still real bytes, byte-identical to the repo, in both artifacts:

git data files: 213 | wheel _bundled files: 213 | sdist _bundled files: 213
MISSING FROM WHEEL: none
MISSING FROM SDIST: none
BYTE MISMATCHES: none (all 213 x2 byte-identical to repo)
wheel symlink members: none
sdist symlink/hardlink members: none
wheel zero-byte: 0
sdist zero-byte: 0
wheel pyc/__pycache__: none
sdist pyc/__pycache__: none

Both artifacts install and run from outside the repo — fresh venvs, HOME redirected, cwd a mktemp -d outside the checkout, all four env vars unset (CAPEVOLVE_RESOURCE_ROOT now in the env -u list too), capevolve.yaml copied from site-packages:

### whl: venv=/tmp/fx250-whl cwd=/tmp/fx250run-whl-TUju sitepkgs=/private/tmp/fx250-whl/.../cap_evolve ###
  "test_reward": 1.0,  "test_delta": 1.0,  "iterations": 3,
  dashboard.html: 35232 bytes

### tar.gz: venv=/tmp/fx250-tar.gz cwd=/tmp/fx250run-tar.gz-x5KY ###
  "test_reward": 1.0,  "test_delta": 1.0,  "iterations": 3,
  dashboard.html: 35238 bytes

Both with --dashboard auto and no extra installed — 35 KB standalone dashboard.html each, which is the evidence behind the [dashboard] decision.

Negative control still fails — pre-PR packaging shape (origin/main:core/pyproject.toml, _bundled/ removed), same command, same env:

$ ls .../site-packages/cap_evolve/_bundled
ls: .../_bundled: No such file or directory
=== NEGATIVE CONTROL: pre-PR wheel, run outside repo, HOME redirected ===
{"error": "skills dir not found; set CAPEVOLVE_SKILLS_DIR or --skills-dir"}

Repo-checkout mode unaffected:

$ PYTHONPATH=/tmp/fx-250/core python -c "from cap_evolve.resources import resource_root; print(resource_root())"
root: /private/tmp/fx-250

What a human must do to release

Steps 1–2 are one-time. Nothing below has been done.

1. Configure PyPI Trusted Publishing (one time, no token to store). On https://pypi.org/manage/account/publishing/ add a pending publisher: project cap-evolve · owner skillberry-ai · repo cap-evolve · workflow release.yml · environment pypi. (cap-evolve is unregistered — GET /pypi/cap-evolve/json → 404 — so the first publish claims it.)

2. Create the pypi GitHub environment (and testpypi for the rehearsal):

gh api -X PUT repos/skillberry-ai/cap-evolve/environments/pypi
gh api -X PUT repos/skillberry-ai/cap-evolve/environments/testpypi   # optional

3. Land the CHANGELOG section for the version (#184). No longer a safety prerequisite — a tag without it fails in build and uploads nothing — but the release cannot succeed without notes. Step 4 tells you whether you have it, so there is no by-hand check any more.

4. Dry run — build + verify only, uploads nothing:

gh workflow run release.yml --repo skillberry-ai/cap-evolve -f target=none
gh run watch --repo skillberry-ai/cap-evolve

Both verify (wheel) and verify (sdist) must print sealed test_reward 1.0. Note: the tag-version and CHANGELOG gates are if: startsWith(github.ref, 'refs/tags/v'), so a dispatch run does not exercise them — step 6 is where they fire, and they fire before the upload.

5. Optional TestPyPI rehearsal (needs step 2's testpypi env + a TestPyPI pending publisher; this DOES upload, to TestPyPI only):

gh workflow run release.yml --repo skillberry-ai/cap-evolve -f target=testpypi
pip install --index-url https://test.pypi.org/simple/ cap-evolve && cap-evolve version

6. THE IRREVERSIBLE STEP — push the tag. A PyPI version can be yanked but never replaced. Bump __version__ first if 0.1.0 is not what you mean to publish (v0.1.0 already exists as a tag, so a re-release needs a new version):

git checkout main && git pull
git tag -a v0.1.0 -m "v0.1.0" && git push origin v0.1.0
gh run watch --repo skillberry-ai/cap-evolve

build now checks the tag/version match, the _bundled symlinks, and the CHANGELOG section, then builds, twine checks, and asserts the artifact contents; verify installs each artifact and runs it for real. Only if all of that is green does publish-pypi upload. A missing CHANGELOG section, a mismatched tag, or a data-free wheel fails while failing is still free.

7. Verify the published package on a clean machine:

python3 -m venv /tmp/v && /tmp/v/bin/pip install cap-evolve && /tmp/v/bin/cap-evolve version

8. Flip the docs. Delete the "Not published yet" callout on docs/INSTALL.md's PyPI path, and update README.md / site/getting-started.html quickstarts to lead with pip install cap-evolve (the ./core from-source path stays, for dev and examples/).

Not in this runbook: publishing capevolve-dashboard. If that ever happens, re-add dashboard = ["capevolve-dashboard"] to core/pyproject.toml and the pip install 'cap-evolve[dashboard]' line to core/README.md in the same commit — test_documented_pip_installs_can_resolve will hold you to doing both or neither.

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.

Publish pip install cap-evolve to PyPI (remove clone-and-pip-./core requirement)

3 participants