Skip to content

Commit 1ab673b

Browse files
OriNachumclaude
andauthored
feat(index): catalog-refresh CI + index --repos-dir (M3 finisher) (#4)
* feat(index): catalog-refresh CI + `--repos-dir` (M3 finisher) tools.culture.dev is hosted on Cloudflare Pages via git-integration, which builds site-astro/ from the *committed* catalog.json. So the live index goes stale as sibling tools change versions or cross the conformance line. This adds the regeneration lane. - `index check` / `index build` gain `--repos-dir DIR`: point the gate + introspection at an explicit directory of candidate checkouts instead of the local sibling-checkout layout. The `build()` / `check_*` API already took `repos_dir`; this exposes it on the CLI so CI (where siblings are cloned to a known path) can drive it. Threading covered by two tests. - `.github/workflows/catalog-refresh.yml`: scheduled (weekly) + workflow_dispatch. Clones each candidate repo (list sourced from the manifest, so it never drifts), installs the agentfront auditor, best-effort installs the conformant tools for `learn --json` enrichment, regenerates catalog.json + public/simple/ via `index build --repos-dir`, and opens a PR — never commits to main, so a human reviews the version/conformance diff before a merge triggers the CF rebuild. Needs a first workflow_dispatch run to validate; private siblings need a SIBLING_REPOS_TOKEN secret. Regenerated the committed catalog to 0.5.1 (the drift guard enforces it). Design doc M3 updated: deploy provisioned by cultureflare#47 (git-integration, option 1), catalog-refresh CI landed; `_redirects` for pip-resolvable /simple/ still open. 47 tests; lint + rubric + markdownlint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdsNvR24i1fGosuQ9AgtVG * fix(ci): harden catalog-refresh workflow (Qodo PR #4) Both findings valid (both on the new workflow): 1. Read-only token breaks PR creation — create-pull-request needs write, but it reused SIBLING_REPOS_TOKEN (documented read-only, for cloning private siblings). Switched the PR step to CATALOG_REFRESH_PR_TOKEN || GITHUB_TOKEN (GITHUB_TOKEN has write via the permissions: block), keeping SIBLING_REPOS_TOKEN for clones only. Documented the two-token split + the GITHUB_TOKEN-opened-PRs-don't-trigger-CI caveat in-file. 2. Token embedded in clone URL — moved auth to GIT_ASKPASS so the clone URL carries only the x-access-token username (not the secret) and nothing is persisted into the cloned repos' git config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdsNvR24i1fGosuQ9AgtVG --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cb3b9e8 commit 1ab673b

8 files changed

Lines changed: 216 additions & 10 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Refresh the committed site catalog from live AgentFront conformance.
2+
#
3+
# tools.culture.dev (M3) is hosted on Cloudflare Pages via git-integration, which
4+
# builds site-astro/ from the *committed* catalog.json. So as sibling tools change
5+
# versions or cross the conformance line, the live index goes stale until the
6+
# catalog is regenerated. This job does that regeneration on a schedule (and on
7+
# demand) and opens a PR — it never commits to main directly, so a human reviews
8+
# the version/conformance diff before a merge triggers the CF rebuild.
9+
#
10+
# Status: needs a first `workflow_dispatch` run to validate in CI.
11+
# Secrets (both optional):
12+
# SIBLING_REPOS_TOKEN read-access PAT to clone *private* siblings; public
13+
# ones clone with the default GITHUB_TOKEN. Without it,
14+
# private siblings are reported as excluded.
15+
# CATALOG_REFRESH_PR_TOKEN write PAT / App token for opening the refresh PR.
16+
# Only needed if you want that PR to trigger CI — PRs
17+
# opened by the default GITHUB_TOKEN do not. Falls back
18+
# to GITHUB_TOKEN (which can still open the PR).
19+
name: catalog-refresh
20+
21+
on:
22+
schedule:
23+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
24+
workflow_dispatch: {}
25+
26+
permissions:
27+
contents: write
28+
pull-requests: write
29+
30+
concurrency:
31+
group: catalog-refresh
32+
cancel-in-progress: false
33+
34+
jobs:
35+
refresh:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Checkout culture-tools
39+
uses: actions/checkout@v4
40+
41+
- name: Set up uv + Python
42+
uses: astral-sh/setup-uv@v5
43+
with:
44+
python-version: "3.12"
45+
46+
- name: Install culture-tools (+ dev deps)
47+
run: uv sync
48+
49+
- name: Install the AgentFront auditor
50+
run: |
51+
uv tool install agentfront
52+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
53+
54+
- name: Clone candidate sibling repos (from the manifest)
55+
env:
56+
GH_TOKEN: ${{ secrets.SIBLING_REPOS_TOKEN || secrets.GITHUB_TOKEN }}
57+
run: |
58+
set -euo pipefail
59+
mkdir -p _siblings
60+
# Keep the token out of clone URLs and the cloned repos' git config: feed
61+
# it through GIT_ASKPASS instead, so the URL carries only the
62+
# x-access-token *username* (not a secret) and nothing is persisted.
63+
askpass="$RUNNER_TEMP/askpass.sh"
64+
printf '#!/bin/sh\necho "$GH_TOKEN"\n' > "$askpass"
65+
chmod +x "$askpass"
66+
export GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0
67+
# The manifest is the single source of truth for what to fetch — emit
68+
# "<owner/repo>\t<dir>" rows so this never drifts from the Python list.
69+
uv run python - > _siblings/manifest.tsv <<'PY'
70+
from culture_tools.index import candidates
71+
for tool in candidates():
72+
print(f"{tool.repo}\t{tool.repo_dir}")
73+
PY
74+
while IFS=$'\t' read -r repo dir; do
75+
[ -z "${repo:-}" ] && continue
76+
echo "::group::clone $repo -> _siblings/$dir"
77+
git clone --depth 1 \
78+
"https://x-access-token@github.com/${repo}.git" \
79+
"_siblings/$dir" \
80+
|| echo "skip: could not clone $repo (private/missing) — it will be excluded"
81+
echo "::endgroup::"
82+
done < _siblings/manifest.tsv
83+
rm -f _siblings/manifest.tsv
84+
85+
- name: Enrich (best-effort) — install conformant tools for `learn --json`
86+
continue-on-error: true
87+
run: |
88+
# Optional layer: populates each listed tool's purpose + command map.
89+
# If a tool can't be installed the catalog still builds; that entry just
90+
# carries no command surface. Never fails the job.
91+
for pkg in agentfront colleague culture-tools; do
92+
uv tool install "$pkg" || echo "skip install $pkg"
93+
done
94+
95+
- name: Regenerate the catalog
96+
run: |
97+
set -euo pipefail
98+
uv run culture-tools index build --out _stage --repos-dir _siblings
99+
cp _stage/catalog.json site-astro/src/data/catalog.json
100+
rm -rf site-astro/public/simple
101+
cp -r _stage/simple site-astro/public/simple
102+
rm -rf _siblings _stage
103+
104+
- name: Open a PR if the catalog changed
105+
uses: peter-evans/create-pull-request@v7
106+
with:
107+
# PR creation needs *write*. SIBLING_REPOS_TOKEN is documented read-only
108+
# (cloning private siblings), so it must NOT be reused here. The default
109+
# GITHUB_TOKEN works via the permissions: block above; note that a PR it
110+
# opens does not itself trigger CI — set CATALOG_REFRESH_PR_TOKEN (a PAT
111+
# or App token) if you want the refresh PR to run checks automatically.
112+
token: ${{ secrets.CATALOG_REFRESH_PR_TOKEN || secrets.GITHUB_TOKEN }}
113+
base: main
114+
branch: bot/catalog-refresh
115+
delete-branch: true
116+
commit-message: "chore(index): refresh catalog from live conformance"
117+
title: "chore(index): refresh tools.culture.dev catalog"
118+
body: |
119+
Automated catalog refresh from live AgentFront conformance
120+
(`culture-tools index build --repos-dir <cloned siblings>`).
121+
122+
**Review the version / conformance diff before merging** — merging to
123+
`main` triggers a Cloudflare Pages rebuild of tools.culture.dev. If a
124+
tool's command surface looks stripped, its package likely failed to
125+
install in the enrichment step (non-fatal) — re-run or hold.
126+
127+
Generated by `.github/workflows/catalog-refresh.yml`.

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
Format follows [Keep a Changelog](https://keepachangelog.com/). This project
66
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.1] - 2026-06-22
9+
10+
### Added
11+
12+
- `index check` / `index build` gain `--repos-dir DIR` — point the conformance
13+
gate and introspection at an explicit directory of candidate checkouts instead
14+
of the local sibling-checkout layout. This is what lets the catalog be
15+
regenerated in CI (where siblings are cloned to a known path), not just from a
16+
developer's workspace. The underlying `build()` / `check_*` API already
17+
accepted `repos_dir`; this exposes it on the CLI.
18+
- `.github/workflows/catalog-refresh.yml` — the **M3 catalog-refresh lane**.
19+
Scheduled (weekly) + `workflow_dispatch`: clones each candidate repo (sourced
20+
from the manifest so it never drifts), installs the `agentfront` auditor,
21+
best-effort installs the conformant tools for `learn --json` enrichment,
22+
regenerates `catalog.json` + `public/simple/` via `index build --repos-dir`,
23+
and **opens a PR** (never commits to `main`) so a human reviews the
24+
version/conformance diff before a merge triggers the Cloudflare Pages rebuild.
25+
Needs a first `workflow_dispatch` run to validate; private siblings require a
26+
`SIBLING_REPOS_TOKEN` secret.
27+
828
## [0.5.0] - 2026-06-22
929

1030
### Added

culture_tools/cli/_commands/index.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
]
3636

3737

38+
def _repos_dir_arg(args: argparse.Namespace) -> Path | None:
39+
"""Resolve ``--repos-dir`` to a Path, or None for the sibling-checkout default.
40+
41+
CI clones each candidate tool into one directory and points the gate at it;
42+
locally, omitting the flag falls back to the workspace sibling-checkout layout.
43+
"""
44+
value = getattr(args, "repos_dir", None)
45+
return Path(value) if value else None
46+
47+
3848
def _index_sections() -> list[dict[str, object]]:
3949
return [
4050
{"title": "Verbs", "items": list(_VERBS)},
@@ -74,9 +84,10 @@ def cmd_index_check(args: argparse.Namespace) -> int:
7484
remediation="install it with: uv tool install agentfront",
7585
)
7686

87+
repos_dir = _repos_dir_arg(args)
7788
name = getattr(args, "tool", None)
7889
if name:
79-
verdict = check_named(name)
90+
verdict = check_named(name, repos_dir=repos_dir)
8091
if verdict is None:
8192
raise CliError(
8293
code=EXIT_USER_ERROR,
@@ -85,7 +96,7 @@ def cmd_index_check(args: argparse.Namespace) -> int:
8596
)
8697
verdicts = [verdict]
8798
else:
88-
verdicts = check_all()
99+
verdicts = check_all(repos_dir=repos_dir)
89100

90101
if json_mode:
91102
emit_result({"verdicts": [v.to_dict() for v in verdicts]}, json_mode=True)
@@ -116,11 +127,12 @@ def cmd_index_build(args: argparse.Namespace) -> int:
116127
remediation="install it with: uv tool install agentfront",
117128
)
118129
out_dir = Path(getattr(args, "out", None) or _DEFAULT_OUT)
130+
repos_dir = _repos_dir_arg(args)
119131
# In --json mode both streams must stay structured; skip the plain
120132
# progress line so stderr carries nothing a JSON consumer can't parse.
121133
if not json_mode:
122134
emit_diagnostic(f"building index into {out_dir} …")
123-
summary = build(out_dir)
135+
summary = build(out_dir, repos_dir=repos_dir)
124136

125137
if json_mode:
126138
emit_result(summary, json_mode=True)
@@ -160,6 +172,10 @@ def register(sub: argparse._SubParsersAction) -> None:
160172
help="Run the AgentFront conformance gate (all candidates, or one TOOL).",
161173
)
162174
ck.add_argument("tool", nargs="?", help="Candidate name; omit to check all.")
175+
ck.add_argument(
176+
"--repos-dir",
177+
help="Directory holding candidate tool checkouts (default: sibling-checkout layout).",
178+
)
163179
ck.add_argument("--json", action="store_true", help="Emit structured JSON.")
164180
ck.set_defaults(func=cmd_index_check)
165181

@@ -172,5 +188,9 @@ def register(sub: argparse._SubParsersAction) -> None:
172188
default=_DEFAULT_OUT,
173189
help=f"Output directory (default: {_DEFAULT_OUT}).",
174190
)
191+
bd.add_argument(
192+
"--repos-dir",
193+
help="Directory holding candidate tool checkouts (default: sibling-checkout layout).",
194+
)
175195
bd.add_argument("--json", action="store_true", help="Emit structured JSON.")
176196
bd.set_defaults(func=cmd_index_build)

docs/design/tools-culture-dev.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ Each milestone is one PR (this repo bumps the version every PR; the
106106
`scripts/sync-catalog.sh` runs `culture-tools index build` and distributes its
107107
output → `src/data/catalog.json` (imported, typed) + `public/simple/` (served
108108
verbatim). Theme is Anthropic-cream, light by default.
109-
- **M3 — Cloudflare deploy lane.** CF Pages build of `site-astro/` (with the
110-
`index build` step wired in), `_worker.js`/`_redirects` as needed, domain bind
111-
via `cultureflare`. CI to regenerate the catalog.
109+
- **M3 — Cloudflare deploy lane.** *In progress.* Deploy provisioned by
110+
cultureflare (issue #47): a CF Pages project `tools-culture-dev` (git-integration,
111+
option 1 — no secrets) builds `site-astro/` from `main` on push and binds
112+
`tools.culture.dev`. **Catalog-refresh CI landed**
113+
`.github/workflows/catalog-refresh.yml` regenerates the committed catalog from
114+
live conformance via `index build --repos-dir <cloned siblings>` and opens a PR
115+
(human-reviewed before a merge triggers the rebuild). Still open: `_redirects`
116+
for a pip-resolvable `/simple/`.
112117
- **M4 — Polish.** Agent affordances: `llms.txt`, markdown twins, sitemap,
113118
conformance badges, SEO. (S3 durable tier can land here or later.)
114119

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "culture-tools"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
description = "tools.culture.dev — the package index for agent-first CLI tools that conform to the agentfront contract."
55
readme = "README.md"
66
license = "Apache-2.0"

site-astro/src/data/catalog.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"generated_with": "culture-tools 0.5.0",
2+
"generated_with": "culture-tools 0.5.1",
33
"contract": "agentfront cli doctor --strict",
44
"count": 3,
55
"tools": [
@@ -159,7 +159,7 @@
159159
"pypi": "culture-tools",
160160
"repo": "agentculture/culture-tools",
161161
"homepage": "https://github.com/agentculture/culture-tools",
162-
"version": "0.5.0",
162+
"version": "0.5.1",
163163
"summary": "tools.culture.dev \u2014 the package index for agent-first CLI tools that conform to the agentfront contract.",
164164
"purpose": "Clonable scaffold for a new AgentCulture mesh agent.",
165165
"backend": "colleague",

tests/test_index.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,40 @@ def test_learn_json_advertises_every_index_verb() -> None:
179179
assert {("index", "check"), ("index", "build"), ("index", "overview")} <= paths
180180

181181

182+
def test_index_check_threads_repos_dir(monkeypatch: pytest.MonkeyPatch) -> None:
183+
# CI clones siblings into one dir and passes --repos-dir; it must reach the gate.
184+
seen: dict[str, object] = {}
185+
186+
def fake_check_all(*, repos_dir=None, runner=None):
187+
seen["repos_dir"] = repos_dir
188+
return []
189+
190+
monkeypatch.setattr("culture_tools.cli._commands.index.auditor_available", lambda: True)
191+
monkeypatch.setattr("culture_tools.cli._commands.index.check_all", fake_check_all)
192+
assert main(["index", "check", "--repos-dir", "/tmp/siblings"]) == 0
193+
assert str(seen["repos_dir"]) == "/tmp/siblings"
194+
195+
196+
def test_index_build_threads_repos_dir(monkeypatch: pytest.MonkeyPatch) -> None:
197+
seen: dict[str, object] = {}
198+
199+
def fake_build(out_dir, **kwargs):
200+
seen["repos_dir"] = kwargs.get("repos_dir")
201+
return {
202+
"out": str(out_dir),
203+
"catalog": "c",
204+
"simple": "s",
205+
"listed": 0,
206+
"excluded": 0,
207+
"candidates": 0,
208+
}
209+
210+
monkeypatch.setattr("culture_tools.cli._commands.index.auditor_available", lambda: True)
211+
monkeypatch.setattr("culture_tools.cli._commands.index.build", fake_build)
212+
assert main(["index", "build", "--repos-dir", "/tmp/sib", "--out", "/tmp/o", "--json"]) == 0
213+
assert str(seen["repos_dir"]) == "/tmp/sib"
214+
215+
182216
# --- PEP 503 emitter (pure) -----------------------------------------------
183217

184218

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)