runspector 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runspector-0.5.0/.githooks/pre-push +33 -0
- runspector-0.5.0/.github/dependabot.yml +28 -0
- runspector-0.5.0/.github/workflows/ci.yml +142 -0
- runspector-0.5.0/.github/workflows/evals.yml +143 -0
- runspector-0.5.0/.github/workflows/mutation.yml +126 -0
- runspector-0.5.0/.github/workflows/release.yml +188 -0
- runspector-0.5.0/.github/workflows/sweep.yml +43 -0
- runspector-0.5.0/.gitignore +35 -0
- runspector-0.5.0/.python-version +1 -0
- runspector-0.5.0/CLAUDE.md +51 -0
- runspector-0.5.0/LICENSE +21 -0
- runspector-0.5.0/PKG-INFO +83 -0
- runspector-0.5.0/README.md +351 -0
- runspector-0.5.0/biome.jsonc +44 -0
- runspector-0.5.0/docs/architecture.md +134 -0
- runspector-0.5.0/docs/pypi.md +56 -0
- runspector-0.5.0/docs/releases/v0.3.1.md +49 -0
- runspector-0.5.0/docs/releases/v0.4.0.md +59 -0
- runspector-0.5.0/docs/releases/v0.4.1.md +29 -0
- runspector-0.5.0/docs/releases/v0.5.0.md +34 -0
- runspector-0.5.0/docs/roadmap.md +117 -0
- runspector-0.5.0/docs/screenshots/coach-light.png +0 -0
- runspector-0.5.0/docs/screenshots/coach.png +0 -0
- runspector-0.5.0/docs/screenshots/demo-card.json +21 -0
- runspector-0.5.0/docs/screenshots/readme-coach.png +0 -0
- runspector-0.5.0/docs/screenshots/readme-runs.png +0 -0
- runspector-0.5.0/docs/screenshots/readme-today.png +0 -0
- runspector-0.5.0/docs/screenshots/readme-trend.png +0 -0
- runspector-0.5.0/docs/screenshots/readme-you.png +0 -0
- runspector-0.5.0/docs/screenshots/runs-light.png +0 -0
- runspector-0.5.0/docs/screenshots/runs.png +0 -0
- runspector-0.5.0/docs/screenshots/today-light.png +0 -0
- runspector-0.5.0/docs/screenshots/today.png +0 -0
- runspector-0.5.0/docs/screenshots/trend-light.png +0 -0
- runspector-0.5.0/docs/screenshots/trend.png +0 -0
- runspector-0.5.0/docs/screenshots/you-light.png +0 -0
- runspector-0.5.0/docs/screenshots/you.png +0 -0
- runspector-0.5.0/docs/video/README.md +18 -0
- runspector-0.5.0/docs/video/assets/audio/answer.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/chat.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/close.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/hook.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/runs.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/strategy.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/today.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/try.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/audio/watch.mp3 +0 -0
- runspector-0.5.0/docs/video/assets/captured.json +9 -0
- runspector-0.5.0/docs/video/assets/fonts/OFL-inter.txt +93 -0
- runspector-0.5.0/docs/video/assets/fonts/OFL-jetbrains-mono.txt +93 -0
- runspector-0.5.0/docs/video/assets/fonts/inter-400.woff2 +0 -0
- runspector-0.5.0/docs/video/assets/fonts/inter-600.woff2 +0 -0
- runspector-0.5.0/docs/video/assets/fonts/inter-700.woff2 +0 -0
- runspector-0.5.0/docs/video/assets/fonts/inter-800.woff2 +0 -0
- runspector-0.5.0/docs/video/assets/fonts/jbmono-400.woff2 +0 -0
- runspector-0.5.0/docs/video/assets/fonts/jbmono-600.woff2 +0 -0
- runspector-0.5.0/docs/video/build.py +373 -0
- runspector-0.5.0/docs/video/hyperframes.json +12 -0
- runspector-0.5.0/docs/video/narration.json +47 -0
- runspector-0.5.0/docs/video/poster.png +0 -0
- runspector-0.5.0/docs/video/runspector-demo.mp4 +0 -0
- runspector-0.5.0/docs/video/scenes.css +202 -0
- runspector-0.5.0/docs/video/tts.py +50 -0
- runspector-0.5.0/evals/README.md +157 -0
- runspector-0.5.0/evals/RESULTS.md +49 -0
- runspector-0.5.0/evals/cases.yaml +1612 -0
- runspector-0.5.0/evals/fixture_gen.py +273 -0
- runspector-0.5.0/evals/run_evals.py +309 -0
- runspector-0.5.0/evals/sim.py +681 -0
- runspector-0.5.0/pyproject.toml +130 -0
- runspector-0.5.0/scripts/install_check.py +216 -0
- runspector-0.5.0/scripts/pii_gate.py +166 -0
- runspector-0.5.0/scripts/screenshots.py +212 -0
- runspector-0.5.0/scripts/video.py +128 -0
- runspector-0.5.0/server.json +28 -0
- runspector-0.5.0/src/runspector/__init__.py +3 -0
- runspector-0.5.0/src/runspector/auth.py +291 -0
- runspector-0.5.0/src/runspector/cli.py +387 -0
- runspector-0.5.0/src/runspector/demo.py +154 -0
- runspector-0.5.0/src/runspector/facts.py +278 -0
- runspector-0.5.0/src/runspector/garmin.py +924 -0
- runspector-0.5.0/src/runspector/habits.py +311 -0
- runspector-0.5.0/src/runspector/logic.py +591 -0
- runspector-0.5.0/src/runspector/mcp_server.py +351 -0
- runspector-0.5.0/src/runspector/migrations/0001_init.sql +99 -0
- runspector-0.5.0/src/runspector/migrations/0002_workouts.sql +13 -0
- runspector-0.5.0/src/runspector/migrations/0003_proposal_items.sql +31 -0
- runspector-0.5.0/src/runspector/migrations/0004_load_balance.sql +19 -0
- runspector-0.5.0/src/runspector/models.py +170 -0
- runspector-0.5.0/src/runspector/onboarding.py +207 -0
- runspector-0.5.0/src/runspector/paths.py +359 -0
- runspector-0.5.0/src/runspector/plan.py +1172 -0
- runspector-0.5.0/src/runspector/planning.py +953 -0
- runspector-0.5.0/src/runspector/profile.py +117 -0
- runspector-0.5.0/src/runspector/skills/coach.md +128 -0
- runspector-0.5.0/src/runspector/skills/zones.md +122 -0
- runspector-0.5.0/src/runspector/snapshot.py +675 -0
- runspector-0.5.0/src/runspector/store.py +967 -0
- runspector-0.5.0/src/runspector/sync.py +286 -0
- runspector-0.5.0/src/runspector/templates.json +51 -0
- runspector-0.5.0/src/runspector/tools.py +756 -0
- runspector-0.5.0/src/runspector/web/__init__.py +0 -0
- runspector-0.5.0/src/runspector/web/agent.py +471 -0
- runspector-0.5.0/src/runspector/web/jobs.py +259 -0
- runspector-0.5.0/src/runspector/web/server.py +1146 -0
- runspector-0.5.0/src/runspector/web/static/app.js +1696 -0
- runspector-0.5.0/src/runspector/web/static/cards.css +126 -0
- runspector-0.5.0/src/runspector/web/static/cards.js +825 -0
- runspector-0.5.0/src/runspector/web/static/chassis.js +308 -0
- runspector-0.5.0/src/runspector/web/static/icon.svg +1 -0
- runspector-0.5.0/src/runspector/web/static/index.html +238 -0
- runspector-0.5.0/src/runspector/web/static/input-guard.js +36 -0
- runspector-0.5.0/src/runspector/web/static/logic.js +476 -0
- runspector-0.5.0/src/runspector/web/static/signin.js +49 -0
- runspector-0.5.0/src/runspector/web/static/theme-boot.js +14 -0
- runspector-0.5.0/src/runspector/web/static/theme.js +85 -0
- runspector-0.5.0/src/runspector/web/static/tokens.css +504 -0
- runspector-0.5.0/src/runspector/web/static/ui.js +478 -0
- runspector-0.5.0/src/runspector/web/static/you.js +297 -0
- runspector-0.5.0/tests/conftest.py +243 -0
- runspector-0.5.0/tests/golden/build_week.txt +3150 -0
- runspector-0.5.0/tests/test_api_contract.py +129 -0
- runspector-0.5.0/tests/test_build_week_golden.py +68 -0
- runspector-0.5.0/tests/test_demo.py +76 -0
- runspector-0.5.0/tests/test_eval_fixture.py +142 -0
- runspector-0.5.0/tests/test_facts.py +97 -0
- runspector-0.5.0/tests/test_garmin.py +669 -0
- runspector-0.5.0/tests/test_habits.py +123 -0
- runspector-0.5.0/tests/test_invariants.py +2232 -0
- runspector-0.5.0/tests/test_js_python_contract.py +358 -0
- runspector-0.5.0/tests/test_legacy_move.py +276 -0
- runspector-0.5.0/tests/test_logic.py +591 -0
- runspector-0.5.0/tests/test_migrations.py +151 -0
- runspector-0.5.0/tests/test_onboarding.py +208 -0
- runspector-0.5.0/tests/test_packaging.py +54 -0
- runspector-0.5.0/tests/test_palette.py +179 -0
- runspector-0.5.0/tests/test_pii_gate.py +216 -0
- runspector-0.5.0/tests/test_planning.py +1015 -0
- runspector-0.5.0/tests/test_profile.py +78 -0
- runspector-0.5.0/tests/test_signin.py +391 -0
- runspector-0.5.0/tests/test_simulation.py +49 -0
- runspector-0.5.0/tests/test_snapshot.py +724 -0
- runspector-0.5.0/tests/test_store.py +922 -0
- runspector-0.5.0/tests/test_sync.py +395 -0
- runspector-0.5.0/tests/test_tools.py +2541 -0
- runspector-0.5.0/tests/test_ui_smoke.py +270 -0
- runspector-0.5.0/tests/test_vendor_api.py +127 -0
- runspector-0.5.0/tests/test_web.py +887 -0
- runspector-0.5.0/tests/test_what_comes_next.py +666 -0
- runspector-0.5.0/uv.lock +1409 -0
- runspector-0.5.0/web-tests/cards.test.mjs +238 -0
- runspector-0.5.0/web-tests/input-guard.test.mjs +74 -0
- runspector-0.5.0/web-tests/logic.test.mjs +602 -0
- runspector-0.5.0/web-tests/progress-strip.test.mjs +94 -0
- runspector-0.5.0/web-tests/signin.test.mjs +41 -0
- runspector-0.5.0/web-tests/stored-before-the-rename.test.mjs +25 -0
- runspector-0.5.0/web-tests/theme.test.mjs +33 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Before main moves: what CI would fail on, here, in about a minute. Commits go
|
|
3
|
+
# straight to main and CI only reports afterwards - and `uv tool install
|
|
4
|
+
# git+...runspector` installs whatever main is at that moment.
|
|
5
|
+
#
|
|
6
|
+
# git config core.hooksPath .githooks # once per clone, opt-in
|
|
7
|
+
# git push --no-verify # skip, when you know why
|
|
8
|
+
set -e
|
|
9
|
+
cd "$(git rev-parse --show-toplevel)"
|
|
10
|
+
|
|
11
|
+
zero=0000000000000000000000000000000000000000
|
|
12
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
13
|
+
case "$local_ref" in refs/tags/*) continue ;; esac
|
|
14
|
+
[ "$local_sha" = "$zero" ] && continue
|
|
15
|
+
[ "$remote_sha" = "$zero" ] && continue
|
|
16
|
+
changed=$(git diff --name-only "$remote_sha" "$local_sha")
|
|
17
|
+
# The coach's prompt is code with its own tests: a change to it ships with
|
|
18
|
+
# an eval run, which is what rewrites evals/RESULTS.md.
|
|
19
|
+
if echo "$changed" | grep -qE '^src/runspector/skills/|^evals/cases\.yaml$' &&
|
|
20
|
+
! echo "$changed" | grep -qx 'evals/RESULTS.md'; then
|
|
21
|
+
echo "pre-push: the coach prompt or the eval cases changed, evals/RESULTS.md did not."
|
|
22
|
+
echo " uv run python evals/run_evals.py (or --no-verify, and say why)"
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
done
|
|
26
|
+
|
|
27
|
+
uv run ruff check
|
|
28
|
+
uv run pytest -q -x -p no:cacheprovider
|
|
29
|
+
uv run python scripts/pii_gate.py
|
|
30
|
+
# The frontend lint, when node is here at all (same version as ci.yml).
|
|
31
|
+
if command -v npx >/dev/null 2>&1; then
|
|
32
|
+
npx --yes @biomejs/biome@2.5.15 lint --error-on-warnings
|
|
33
|
+
fi
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# The workflow pins its actions to commit SHAs, which is only as good as the
|
|
2
|
+
# habit of moving them: the first three pins aged into a Node 20 deprecation
|
|
3
|
+
# notice within a week, and nothing said so. Dependabot files the PR; CI on the
|
|
4
|
+
# PR is the check that the new SHA still does what the old one did.
|
|
5
|
+
version: 2
|
|
6
|
+
updates:
|
|
7
|
+
- package-ecosystem: github-actions
|
|
8
|
+
directory: /
|
|
9
|
+
schedule:
|
|
10
|
+
interval: weekly
|
|
11
|
+
# Keeps the `# vX.Y.Z` comment next to the SHA up to date, so the pin stays
|
|
12
|
+
# readable by a human.
|
|
13
|
+
commit-message:
|
|
14
|
+
prefix: "ci"
|
|
15
|
+
- package-ecosystem: uv
|
|
16
|
+
directory: /
|
|
17
|
+
schedule:
|
|
18
|
+
interval: weekly
|
|
19
|
+
# `garminconnect` tracks an unofficial API; a minor bump there is exactly
|
|
20
|
+
# the change that needs a human to run `runspector sync` once before merging.
|
|
21
|
+
commit-message:
|
|
22
|
+
prefix: "deps"
|
|
23
|
+
ignore:
|
|
24
|
+
# `pyproject.toml` pins `mcp<2` on purpose: 2.x renamed FastMCP and
|
|
25
|
+
# changed the server API. Dependabot's very first PR proposed 2.2.0
|
|
26
|
+
# anyway and CI rejected it; a major bump there is a port, not a bump.
|
|
27
|
+
- dependency-name: mcp
|
|
28
|
+
update-types: ["version-update:semver-major"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# One matrix per branch. Pushing twice in a minute otherwise leaves the first
|
|
9
|
+
# six legs running against a commit nobody is waiting for any more. A push to
|
|
10
|
+
# `main` is not cancelled: its result is the badge.
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ci-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
14
|
+
|
|
15
|
+
# Least privilege: nothing in this workflow writes to the repo, packages or
|
|
16
|
+
# releases. Job-level grants stay opt-in from here.
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
test:
|
|
22
|
+
# The slowest leg (Windows) measures three to four minutes, so fifteen is
|
|
23
|
+
# around four times the headroom and still catches a hang early. Without it
|
|
24
|
+
# the default is six HOURS per leg — and this suite has already had one:
|
|
25
|
+
# headless Chrome on the macOS runners never returns, it only ever hit a
|
|
26
|
+
# timeout inside the test.
|
|
27
|
+
timeout-minutes: 15
|
|
28
|
+
strategy:
|
|
29
|
+
fail-fast: false
|
|
30
|
+
matrix:
|
|
31
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
32
|
+
# `requires-python = ">=3.12"` is a promise about every version above it;
|
|
33
|
+
# one unpinned interpreter per OS tested none of them in particular.
|
|
34
|
+
python: ["3.12", "3.13"]
|
|
35
|
+
# Coverage is measured on ONE leg: the number is the same code on every
|
|
36
|
+
# OS, but a floor checked six times fails on whichever leg skips the most
|
|
37
|
+
# platform branches. 93 % at the time the floor was set (v0.4.1); the
|
|
38
|
+
# floor only stops it from eroding unnoticed.
|
|
39
|
+
include:
|
|
40
|
+
- os: ubuntu-latest
|
|
41
|
+
python: "3.12"
|
|
42
|
+
coverage: coverage # the value lands in the check's name: keep it short
|
|
43
|
+
runs-on: ${{ matrix.os }}
|
|
44
|
+
steps:
|
|
45
|
+
# Actions are pinned to commit SHAs, not tags: a tag is mutable, and these
|
|
46
|
+
# three run with access to the workflow token before any test does.
|
|
47
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
48
|
+
with:
|
|
49
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
50
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python }}
|
|
53
|
+
# Keyed on uv.lock, so the cache is only reused for the exact
|
|
54
|
+
# dependency set it was built from.
|
|
55
|
+
enable-cache: true
|
|
56
|
+
cache-dependency-glob: uv.lock
|
|
57
|
+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
58
|
+
with:
|
|
59
|
+
node-version: 22
|
|
60
|
+
# The matrix is only real if the interpreter actually changes with it.
|
|
61
|
+
# `.python-version` pins the local dev interpreter and could quietly win
|
|
62
|
+
# over the matrix, which would leave one leg testing the other's version
|
|
63
|
+
# and the README claiming two. So every `uv run` names the version, and
|
|
64
|
+
# this step fails the job if what arrives is not what was asked for.
|
|
65
|
+
- name: The interpreter is the one this leg is for
|
|
66
|
+
run: |
|
|
67
|
+
uv run --python ${{ matrix.python }} python -c "import sys; got = '%d.%d' % sys.version_info[:2]; want = '${{ matrix.python }}'; print('running', got); raise SystemExit(0 if got == want else f'expected {want}, got {got}')"
|
|
68
|
+
|
|
69
|
+
- name: Lint
|
|
70
|
+
run: uv run --python ${{ matrix.python }} ruff check
|
|
71
|
+
- name: Python tests (no database server, no network, no Garmin account)
|
|
72
|
+
shell: bash # one syntax for the empty $COVERAGE on every leg, Windows included
|
|
73
|
+
env:
|
|
74
|
+
COVERAGE: ${{ matrix.coverage && '--cov=runspector --cov-branch --cov-report=term:skip-covered --cov-fail-under=92' || '' }}
|
|
75
|
+
# Outside the tree: the data file holds absolute paths, and pii_gate
|
|
76
|
+
# (two steps down) reads every file in the checkout.
|
|
77
|
+
COVERAGE_FILE: ${{ runner.temp }}/.coverage
|
|
78
|
+
run: uv run --python ${{ matrix.python }} pytest -q $COVERAGE
|
|
79
|
+
- name: Frontend logic tests
|
|
80
|
+
run: node --test "web-tests/*.test.mjs"
|
|
81
|
+
- name: Eval suite is well-formed (dry run, no model calls)
|
|
82
|
+
run: uv run --python ${{ matrix.python }} python evals/run_evals.py --dry-run
|
|
83
|
+
- name: Nothing personal in the tree
|
|
84
|
+
run: uv run --python ${{ matrix.python }} python scripts/pii_gate.py
|
|
85
|
+
|
|
86
|
+
# The shipped frontend has no build step, so nothing reads it before a
|
|
87
|
+
# browser does. Biome lints it (rules and the reasons for the ones switched
|
|
88
|
+
# off: biome.jsonc). Once, not per leg: the result cannot depend on the OS.
|
|
89
|
+
frontend:
|
|
90
|
+
timeout-minutes: 5
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
94
|
+
with:
|
|
95
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
96
|
+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
97
|
+
with:
|
|
98
|
+
node-version: 22
|
|
99
|
+
package-manager-cache: false # no package.json, nothing to cache
|
|
100
|
+
- name: Biome
|
|
101
|
+
run: npx --yes @biomejs/biome@2.5.15 lint --error-on-warnings
|
|
102
|
+
|
|
103
|
+
# The suite above proves the code from inside the checkout. This job proves
|
|
104
|
+
# the PACKAGE from outside it: build the wheel, `uv tool install` it into a
|
|
105
|
+
# throw-away tool dir the way a user would, and drive the installed
|
|
106
|
+
# executable through the README's quick start - demo over HTTP, doctor,
|
|
107
|
+
# sync, login without a terminal, the MCP handshake. It is the "other
|
|
108
|
+
# computer" the author does not have: a machine that has never seen the
|
|
109
|
+
# source tree. Standard-library Python drives it, so it runs on whatever
|
|
110
|
+
# interpreter the runner has, and the installed tool brings its own.
|
|
111
|
+
install:
|
|
112
|
+
timeout-minutes: 15
|
|
113
|
+
strategy:
|
|
114
|
+
fail-fast: false
|
|
115
|
+
matrix:
|
|
116
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
117
|
+
runs-on: ${{ matrix.os }}
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
120
|
+
with:
|
|
121
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
122
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
123
|
+
with:
|
|
124
|
+
python-version: "3.12"
|
|
125
|
+
enable-cache: true
|
|
126
|
+
cache-dependency-glob: uv.lock
|
|
127
|
+
- name: Install the wheel as a user and use it as one
|
|
128
|
+
run: uv run --no-project python scripts/install_check.py
|
|
129
|
+
|
|
130
|
+
# The workflows themselves, audited: unpinned actions, template injection,
|
|
131
|
+
# credentials left in the checkout, caches feeding a release. Every workflow
|
|
132
|
+
# here runs with a token, and a Dependabot PR edits them weekly.
|
|
133
|
+
workflows:
|
|
134
|
+
timeout-minutes: 5
|
|
135
|
+
runs-on: ubuntu-latest
|
|
136
|
+
steps:
|
|
137
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
138
|
+
with:
|
|
139
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
140
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
141
|
+
- name: zizmor
|
|
142
|
+
run: uvx zizmor@1.31.1 --min-severity medium .github/workflows
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
name: evals
|
|
2
|
+
|
|
3
|
+
# The coach-prompt evals (evals/run_evals.py) with a real model, once a week -
|
|
4
|
+
# but only in a week in which main moved: an unchanged prompt on an unchanged
|
|
5
|
+
# model measures the weather and spends the subscription for it. By hand
|
|
6
|
+
# (workflow_dispatch) it always runs.
|
|
7
|
+
#
|
|
8
|
+
# The model calls go through the maintainer's Claude subscription: the secret
|
|
9
|
+
# CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`, valid one year) lives in
|
|
10
|
+
# the environment `evals`, which only `main` may use. Forks and pull requests
|
|
11
|
+
# never see it, and the job that decides whether to run does not touch it, so
|
|
12
|
+
# a quiet week spends nothing. The result is the job summary and an artifact;
|
|
13
|
+
# evals/RESULTS.md and the README badge stay a local, committed record.
|
|
14
|
+
#
|
|
15
|
+
# Renew the token:
|
|
16
|
+
# claude setup-token
|
|
17
|
+
# gh secret set CLAUDE_CODE_OAUTH_TOKEN --env evals -R edxleon/runspector
|
|
18
|
+
# gh variable set CLAUDE_TOKEN_CREATED --body YYYY-MM-DD -R edxleon/runspector
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
schedule:
|
|
22
|
+
- cron: "41 5 * * 1" # Mondays, 05:41 UTC - after the sweep
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: evals
|
|
27
|
+
cancel-in-progress: false
|
|
28
|
+
|
|
29
|
+
permissions:
|
|
30
|
+
contents: read
|
|
31
|
+
|
|
32
|
+
jobs:
|
|
33
|
+
changed:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
timeout-minutes: 5
|
|
36
|
+
outputs:
|
|
37
|
+
run: ${{ steps.check.outputs.run }}
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
40
|
+
with:
|
|
41
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
42
|
+
fetch-depth: 0 # the last week of history, not one commit
|
|
43
|
+
- name: Did main move in the last 7 days?
|
|
44
|
+
id: check
|
|
45
|
+
env:
|
|
46
|
+
EVENT: ${{ github.event_name }}
|
|
47
|
+
run: |
|
|
48
|
+
commits=$(git log --since="7 days ago" --format='%h %s' HEAD)
|
|
49
|
+
if [ "$EVENT" = "workflow_dispatch" ]; then
|
|
50
|
+
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
51
|
+
echo "started by hand - the evals run"
|
|
52
|
+
elif [ -n "$commits" ]; then
|
|
53
|
+
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
54
|
+
echo "commits on main in the last 7 days:"; echo "$commits"
|
|
55
|
+
else
|
|
56
|
+
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
57
|
+
echo "no commit on main in the last 7 days - no evals, nothing spent" | tee -a "$GITHUB_STEP_SUMMARY"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
evals:
|
|
61
|
+
needs: changed
|
|
62
|
+
if: needs.changed.outputs.run == 'true'
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
environment: evals
|
|
65
|
+
# 29 cases at ~20 s for the answer and ~10 s for the judge: about 15 min.
|
|
66
|
+
timeout-minutes: 45
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
69
|
+
with:
|
|
70
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
71
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
72
|
+
with:
|
|
73
|
+
python-version: "3.12"
|
|
74
|
+
# No cache in the one job that holds the token: a cache any workflow
|
|
75
|
+
# run can write must not feed a step that sees it.
|
|
76
|
+
enable-cache: false
|
|
77
|
+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
78
|
+
with:
|
|
79
|
+
node-version: 22
|
|
80
|
+
package-manager-cache: false # same reason as above
|
|
81
|
+
# Pinned: the version the committed evals/RESULTS.md was produced with.
|
|
82
|
+
# A new CLI is a deliberate bump here, not a surprise in a red run.
|
|
83
|
+
- name: Claude Code CLI
|
|
84
|
+
run: npm install -g @anthropic-ai/claude-code@2.1.312
|
|
85
|
+
|
|
86
|
+
- name: How long the token has left
|
|
87
|
+
env:
|
|
88
|
+
CREATED: ${{ vars.CLAUDE_TOKEN_CREATED }}
|
|
89
|
+
run: |
|
|
90
|
+
python3 - <<'EOF'
|
|
91
|
+
import datetime as dt, os
|
|
92
|
+
created = os.environ.get("CREATED", "").strip()
|
|
93
|
+
if not created:
|
|
94
|
+
print("::notice::set CLAUDE_TOKEN_CREATED (gh variable set ...) to be warned before the token expires")
|
|
95
|
+
raise SystemExit(0)
|
|
96
|
+
expires = dt.date.fromisoformat(created) + dt.timedelta(days=365)
|
|
97
|
+
left = (expires - dt.date.today()).days
|
|
98
|
+
print(f"Claude token created {created}, expires {expires} ({left} days)")
|
|
99
|
+
if left <= 30:
|
|
100
|
+
print(f"::warning::the Claude token expires on {expires} ({left} days) - renew it, see the head of evals.yml")
|
|
101
|
+
EOF
|
|
102
|
+
|
|
103
|
+
# One call before the suite: an expired or revoked token is one clear line, not
|
|
104
|
+
# every case red with the same auth error.
|
|
105
|
+
- name: The token works
|
|
106
|
+
env:
|
|
107
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
108
|
+
run: |
|
|
109
|
+
if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
|
|
110
|
+
echo "::error::no CLAUDE_CODE_OAUTH_TOKEN in the environment 'evals'"; exit 1
|
|
111
|
+
fi
|
|
112
|
+
if ! out=$(echo "Reply with the single word OK." |
|
|
113
|
+
claude --print --safe-mode --tools "" --model sonnet 2>&1); then
|
|
114
|
+
echo "::error::Claude refused the token (expired, revoked or out of quota) - renew: claude setup-token, then gh secret set CLAUDE_CODE_OAUTH_TOKEN --env evals -R edxleon/runspector"
|
|
115
|
+
echo "$out" | tail -n 3
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
echo "token ok"
|
|
119
|
+
|
|
120
|
+
- name: Evals
|
|
121
|
+
env:
|
|
122
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
123
|
+
# pyyaml comes from the dev group in uv.lock, hash-checked (--locked):
|
|
124
|
+
# `--with pyyaml` would pull whatever PyPI serves today into the one
|
|
125
|
+
# job that holds the token.
|
|
126
|
+
run: uv run --python 3.12 --locked python evals/run_evals.py
|
|
127
|
+
|
|
128
|
+
- name: Result as the job summary
|
|
129
|
+
if: always()
|
|
130
|
+
run: |
|
|
131
|
+
if [ -f evals/RESULTS.md ]; then cat evals/RESULTS.md >> "$GITHUB_STEP_SUMMARY"; fi
|
|
132
|
+
|
|
133
|
+
# The answers and the judge's reasons of every case: what a red case is
|
|
134
|
+
# read from. Synthetic athletes only - nothing personal is in them.
|
|
135
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
136
|
+
if: always()
|
|
137
|
+
with:
|
|
138
|
+
name: evals-${{ github.run_number }}
|
|
139
|
+
path: |
|
|
140
|
+
evals/RESULTS.md
|
|
141
|
+
evals/last-run.json
|
|
142
|
+
retention-days: 30
|
|
143
|
+
if-no-files-found: ignore
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
name: mutation
|
|
2
|
+
|
|
3
|
+
# Mutation testing of the rule modules (`[tool.mutmut]` in pyproject.toml), once
|
|
4
|
+
# a week. 93 % branch coverage says the tests RUN the rules; this says whether
|
|
5
|
+
# they would NOTICE a rule changing: mutmut flips a comparison, a constant, an
|
|
6
|
+
# `and` into an `or`, runs the tests that cover that function, and a mutant no
|
|
7
|
+
# test kills is a change to the coach's decisions that would ship green.
|
|
8
|
+
#
|
|
9
|
+
# Code written with an AI and tested by the same AI tends to test what the code
|
|
10
|
+
# does rather than what it should do; a survivor is where that shows. It is
|
|
11
|
+
# the bug hunt, like sweep.yml, not a gate: survivors are expected, and the
|
|
12
|
+
# useful ones become a scenario test in tests/test_what_comes_next.py, red
|
|
13
|
+
# against the mutant first. Linux only: mutmut needs a clean fork().
|
|
14
|
+
#
|
|
15
|
+
# One job per module, side by side: all ~5,600 mutants on one 4-core runner
|
|
16
|
+
# do not fit in three hours. Measured per module, 4 workers in a Linux
|
|
17
|
+
# container (v0.4.1): habits 601 mutants in 4 min, planning 1,559 in 23, plan
|
|
18
|
+
# 2,387 in 26; the summary 1-2 min after that.
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
schedule:
|
|
22
|
+
- cron: "23 2 * * 1" # Mondays, 02:23 UTC
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: mutation
|
|
27
|
+
cancel-in-progress: false # a run by hand waits; it does not cancel the Monday run
|
|
28
|
+
|
|
29
|
+
permissions:
|
|
30
|
+
contents: read
|
|
31
|
+
|
|
32
|
+
jobs:
|
|
33
|
+
mutation:
|
|
34
|
+
strategy:
|
|
35
|
+
fail-fast: false
|
|
36
|
+
matrix:
|
|
37
|
+
module: [logic, planning, plan, habits]
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
timeout-minutes: 150
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
42
|
+
with:
|
|
43
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
44
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.12"
|
|
47
|
+
enable-cache: true
|
|
48
|
+
cache-dependency-glob: uv.lock
|
|
49
|
+
# The suite runs the shipped logic.js through node (test_js_python_contract).
|
|
50
|
+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
51
|
+
with:
|
|
52
|
+
node-version: 22
|
|
53
|
+
package-manager-cache: false
|
|
54
|
+
|
|
55
|
+
# mutmut and its dependencies come from the `mutation` group in uv.lock,
|
|
56
|
+
# hash-checked: `--with` would resolve them fresh from PyPI every week,
|
|
57
|
+
# into a job whose uv cache ci.yml and sweep.yml restore.
|
|
58
|
+
# Red only when mutmut itself cannot run (the clean suite fails in its
|
|
59
|
+
# copy of the tree) - surviving mutants are the result, not an error.
|
|
60
|
+
- name: Mutate the rule modules
|
|
61
|
+
# Its own limit, under the job's: the summary and the artifact still
|
|
62
|
+
# run after a cancel, and mutmut has saved every result so far.
|
|
63
|
+
timeout-minutes: 120
|
|
64
|
+
env:
|
|
65
|
+
MODULE: ${{ matrix.module }}
|
|
66
|
+
run: uv run --python 3.12 --locked --group mutation mutmut run --max-children 4 "runspector.$MODULE.*"
|
|
67
|
+
|
|
68
|
+
- name: Survivors as the job summary
|
|
69
|
+
if: always()
|
|
70
|
+
env:
|
|
71
|
+
MODULE: ${{ matrix.module }}
|
|
72
|
+
run: |
|
|
73
|
+
# `mutmut results` lists every mutant not killed, of every module (the
|
|
74
|
+
# other jobs' as "not checked"). Only this module's `survived` is a
|
|
75
|
+
# test that is missing; `timeout` and `no tests` are counted apart.
|
|
76
|
+
uv run --python 3.12 --locked --group mutation mutmut results \
|
|
77
|
+
| grep -E "^ *runspector\.$MODULE\." > results.txt || true
|
|
78
|
+
grep -E ': survived$' results.txt > survivors.txt || true
|
|
79
|
+
{
|
|
80
|
+
echo "## $MODULE.py - surviving mutants: $(wc -l < survivors.txt)"
|
|
81
|
+
echo
|
|
82
|
+
echo "Not survived, not killed: $(grep -cvE ': survived$' results.txt || true)"
|
|
83
|
+
echo "($(cut -d: -f2 results.txt | grep -vx ' survived' | sort | uniq -c | tr '\n' ' '))"
|
|
84
|
+
echo
|
|
85
|
+
echo "Per function (the full diffs are in the artifact):"
|
|
86
|
+
echo '```'
|
|
87
|
+
sed -E 's/__mutmut_[0-9]+.*//' survivors.txt | sort | uniq -c | sort -nr
|
|
88
|
+
echo '```'
|
|
89
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
90
|
+
# The diffs in one pass: `mutmut show` re-parses the whole mutated
|
|
91
|
+
# module per call (12 s for plan.py), which for a few hundred
|
|
92
|
+
# survivors outlasts the job. The mutated file holds every mutant as
|
|
93
|
+
# `<name>__mutmut_N` next to `<name>__mutmut_orig`.
|
|
94
|
+
python3 - survivors.txt > survivors.diff <<'PY'
|
|
95
|
+
import ast, difflib, os, sys
|
|
96
|
+
|
|
97
|
+
module = os.environ["MODULE"]
|
|
98
|
+
path = f"mutants/src/runspector/{module}.py"
|
|
99
|
+
source = open(path, encoding="utf-8").read()
|
|
100
|
+
funcs = {n.name: n for n in ast.walk(ast.parse(source))
|
|
101
|
+
if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef)}
|
|
102
|
+
for line in open(sys.argv[1], encoding="utf-8"):
|
|
103
|
+
key = line.split(":")[0].strip()
|
|
104
|
+
name = key.removeprefix(f"runspector.{module}.")
|
|
105
|
+
base = name.rpartition("__mutmut_")[0]
|
|
106
|
+
orig, mutant = funcs.get(base + "__mutmut_orig"), funcs.get(name)
|
|
107
|
+
if orig is None or mutant is None:
|
|
108
|
+
print(f"# {key}: not in {path}\n")
|
|
109
|
+
continue
|
|
110
|
+
a = ast.get_source_segment(source, orig).replace(base + "__mutmut_orig", base)
|
|
111
|
+
b = ast.get_source_segment(source, mutant).replace(name, base)
|
|
112
|
+
print(f"# {key}")
|
|
113
|
+
print("\n".join(difflib.unified_diff(a.splitlines(), b.splitlines(), "original",
|
|
114
|
+
"mutant", lineterm="", n=2)) + "\n")
|
|
115
|
+
PY
|
|
116
|
+
|
|
117
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
118
|
+
if: always()
|
|
119
|
+
with:
|
|
120
|
+
name: mutation-${{ github.run_number }}-${{ matrix.module }}
|
|
121
|
+
path: |
|
|
122
|
+
results.txt
|
|
123
|
+
survivors.txt
|
|
124
|
+
survivors.diff
|
|
125
|
+
retention-days: 30
|
|
126
|
+
if-no-files-found: ignore
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# A pushed tag `vX.Y.Z` becomes the GitHub release, with the wheel and the
|
|
4
|
+
# sdist built HERE and signed with a build-provenance attestation, the same
|
|
5
|
+
# files on PyPI, and the MCP registry entry pointing at them. `build` and
|
|
6
|
+
# `test` run without rights; `release`, `pypi` and `mcp-registry` hold only the
|
|
7
|
+
# rights their own step needs and run no project code. Until now a
|
|
8
|
+
# release was four hand steps on one laptop (bump, build, create, upload), and
|
|
9
|
+
# nothing checked that the tag, `pyproject.toml` and `__version__` agree - a
|
|
10
|
+
# wheel named 0.3.0 on a v0.3.1 release would have passed.
|
|
11
|
+
#
|
|
12
|
+
# git tag v0.3.2 && git push origin v0.3.2
|
|
13
|
+
#
|
|
14
|
+
# The notes come from `docs/releases/<tag>.md` when it exists, else GitHub
|
|
15
|
+
# generates them from the commits. A release that already exists (created by
|
|
16
|
+
# hand) keeps its notes and gets the files attached.
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
tags: ["v*.*.*"]
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: release-${{ github.ref }}
|
|
24
|
+
cancel-in-progress: false
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
# The suite, with no rights at all. Everything the tests import - this code
|
|
31
|
+
# and every dependency - runs here, so it must not run where the job can
|
|
32
|
+
# mint an OIDC token or write a release.
|
|
33
|
+
test:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
timeout-minutes: 15
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
|
+
with:
|
|
39
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
40
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.12"
|
|
43
|
+
enable-cache: false # no cache anywhere on the way to a release
|
|
44
|
+
# The Python suite runs the shipped logic.js through node
|
|
45
|
+
# (tests/test_js_python_contract.py): the same node as ci.yml.
|
|
46
|
+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
47
|
+
with:
|
|
48
|
+
node-version: 22
|
|
49
|
+
package-manager-cache: false
|
|
50
|
+
- name: The tag, pyproject.toml, __version__ and server.json name the same version
|
|
51
|
+
env:
|
|
52
|
+
TAG: ${{ github.ref_name }}
|
|
53
|
+
run: |
|
|
54
|
+
uv run --python 3.12 --locked python - <<'EOF'
|
|
55
|
+
import json, os, re, tomllib
|
|
56
|
+
tag = os.environ["TAG"].removeprefix("v")
|
|
57
|
+
project = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"]
|
|
58
|
+
init = re.search(r'__version__ = "([^"]+)"', open("src/runspector/__init__.py").read())[1]
|
|
59
|
+
# The registry entry pins the PyPI version a client installs: a stale
|
|
60
|
+
# one would hand every new user the previous release.
|
|
61
|
+
server = json.load(open("server.json"))
|
|
62
|
+
listed = [server["version"], *(p["version"] for p in server["packages"])]
|
|
63
|
+
print(f"tag {tag}, pyproject {project}, __version__ {init}, server.json {listed}")
|
|
64
|
+
raise SystemExit(0 if {tag, project, init, *listed} == {tag}
|
|
65
|
+
else "versions disagree - bump all of them, re-tag")
|
|
66
|
+
EOF
|
|
67
|
+
# The matrix in ci.yml ran on this commit when it reached main; this is
|
|
68
|
+
# the short version again, so a tag on a commit that never went through
|
|
69
|
+
# CI (or a red one) cannot become a release.
|
|
70
|
+
- name: Lint and tests
|
|
71
|
+
run: |
|
|
72
|
+
uv run --python 3.12 --locked ruff check
|
|
73
|
+
uv run --python 3.12 --locked pytest -q
|
|
74
|
+
uv run --python 3.12 --locked python scripts/pii_gate.py
|
|
75
|
+
|
|
76
|
+
# The package, built with no rights: `uv build` runs the build backend
|
|
77
|
+
# (hatchling, pinned exactly in pyproject.toml) and nothing of this project.
|
|
78
|
+
# Built before anything else touches the tree, and handed on as an artifact.
|
|
79
|
+
build:
|
|
80
|
+
runs-on: ubuntu-latest
|
|
81
|
+
timeout-minutes: 10
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
84
|
+
with:
|
|
85
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
86
|
+
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
87
|
+
with:
|
|
88
|
+
python-version: "3.12"
|
|
89
|
+
enable-cache: false # no cache anywhere on the way to a release
|
|
90
|
+
- name: Build
|
|
91
|
+
run: uv build
|
|
92
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
93
|
+
with:
|
|
94
|
+
name: dist
|
|
95
|
+
path: dist/
|
|
96
|
+
retention-days: 1
|
|
97
|
+
if-no-files-found: error
|
|
98
|
+
|
|
99
|
+
# Attest and publish - no Python, no project code, no dependency runs here,
|
|
100
|
+
# only the checkout (for the notes file), GitHub's attest action and `gh`.
|
|
101
|
+
release:
|
|
102
|
+
needs: [build, test]
|
|
103
|
+
runs-on: ubuntu-latest
|
|
104
|
+
timeout-minutes: 10
|
|
105
|
+
permissions:
|
|
106
|
+
contents: write # create the release, upload its files
|
|
107
|
+
id-token: write # the attestation's OIDC identity
|
|
108
|
+
attestations: write # store the attestation
|
|
109
|
+
steps:
|
|
110
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
111
|
+
with:
|
|
112
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
113
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
114
|
+
with:
|
|
115
|
+
name: dist
|
|
116
|
+
path: dist/
|
|
117
|
+
|
|
118
|
+
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
119
|
+
with:
|
|
120
|
+
subject-path: dist/*
|
|
121
|
+
|
|
122
|
+
- name: Release
|
|
123
|
+
env:
|
|
124
|
+
GH_TOKEN: ${{ github.token }}
|
|
125
|
+
TAG: ${{ github.ref_name }}
|
|
126
|
+
run: |
|
|
127
|
+
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
128
|
+
gh release upload "$TAG" dist/* --clobber
|
|
129
|
+
elif [ -f "docs/releases/$TAG.md" ]; then
|
|
130
|
+
gh release create "$TAG" dist/* --verify-tag --title "$TAG" --notes-file "docs/releases/$TAG.md"
|
|
131
|
+
else
|
|
132
|
+
gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# PyPI through a trusted publisher: no token exists anywhere, PyPI trusts the
|
|
136
|
+
# OIDC identity of this workflow in the `pypi` environment. Like `release`, it
|
|
137
|
+
# runs no project code and uploads the files `build` made. After `release`, not
|
|
138
|
+
# beside it: a version on PyPI can never be replaced, so it goes out only once
|
|
139
|
+
# the attestation and the GitHub release for exactly these files exist.
|
|
140
|
+
pypi:
|
|
141
|
+
needs: [release]
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
timeout-minutes: 10
|
|
144
|
+
environment: pypi
|
|
145
|
+
permissions:
|
|
146
|
+
id-token: write # the trusted-publisher identity, nothing else
|
|
147
|
+
steps:
|
|
148
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
149
|
+
with:
|
|
150
|
+
name: dist
|
|
151
|
+
path: dist/
|
|
152
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
153
|
+
|
|
154
|
+
# The MCP registry entry points at the PyPI package and checks the `mcp-name`
|
|
155
|
+
# line in its README, so it runs after `pypi`. mcp-publisher is a downloaded
|
|
156
|
+
# binary in a job that can mint an OIDC token: pinned to a version and checked
|
|
157
|
+
# against the hash taken when it was pinned, so nothing floats in here.
|
|
158
|
+
mcp-registry:
|
|
159
|
+
needs: [pypi]
|
|
160
|
+
runs-on: ubuntu-latest
|
|
161
|
+
timeout-minutes: 15
|
|
162
|
+
permissions:
|
|
163
|
+
contents: read
|
|
164
|
+
id-token: write # the registry's GitHub OIDC login for io.github.edxleon/*
|
|
165
|
+
env:
|
|
166
|
+
MCP_PUBLISHER_VERSION: v1.8.1
|
|
167
|
+
MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
|
|
168
|
+
steps:
|
|
169
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
170
|
+
with:
|
|
171
|
+
persist-credentials: false # nothing here pushes; no token left in .git/config
|
|
172
|
+
- name: mcp-publisher, verified
|
|
173
|
+
run: |
|
|
174
|
+
curl -fsSL -o mcp-publisher.tar.gz \
|
|
175
|
+
"https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz"
|
|
176
|
+
echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c -
|
|
177
|
+
tar -xzf mcp-publisher.tar.gz mcp-publisher
|
|
178
|
+
# PyPI serves a fresh upload's README a little after the upload returns,
|
|
179
|
+
# and the registry reads it from there - so a few tries, not one.
|
|
180
|
+
- name: Publish server.json
|
|
181
|
+
run: |
|
|
182
|
+
./mcp-publisher login github-oidc
|
|
183
|
+
for attempt in 1 2 3 4 5 6; do
|
|
184
|
+
./mcp-publisher publish && exit 0
|
|
185
|
+
echo "attempt $attempt failed - PyPI may not show the new release yet"
|
|
186
|
+
sleep 30
|
|
187
|
+
done
|
|
188
|
+
exit 1
|