Files
cc-ci-orchestrator/scripts/gen-cctest-skills.py
T

185 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Generate /cctest-* wrapper skills from the autonomic-recipe-maintainer submodule.
The ARM toolkit (references/recipe-maintainer) carries ~30 recipe-maintenance skills
that operate against the recipe-maintainer TEST server ("cctest") + local abra sandbox — a
different substrate than the cc-ci skills in this repo. To give the operator ONE interface,
every ARM skill is exposed here as `cctest-<name>`: a thin wrapper whose frontmatter carries
ARM's own description (so discovery works) and whose body points at the canonical SKILL.md in
the submodule, sets the execution context, and states the policy overrides.
Run after every submodule bump:
python3 scripts/gen-cctest-skills.py
It removes cctest-* skills whose ARM source disappeared and (re)writes the rest, in BOTH
.opencode/skills/ (canonical) and .claude/skills/ (thin pointer), then prints a summary.
Commit the result.
"""
from __future__ import annotations
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "references/recipe-maintainer"
ARM_SKILLS = VENDOR / ".opencode/skills"
PREFIX = "cctest-"
BODY_TEMPLATE = """# {wrapped} (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/{name}/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
"""
# Per-skill extra body sections appended after BODY_TEMPLATE (survive regeneration).
PER_SKILL_NOTES: dict[str, str] = {
"recipe-upstream": """
**Sandboxed vs non-sandboxed mode.** This skill needs only git + (optionally) the Gitea API —
not the test server — so it can run either way. Probe, then follow that branch:
- **Sandboxed** (ARM env configured: `test-ssh/.testenv` with `GITEA_USERNAME`/`GITEA_PASSWORD`/
`GITEA_URL`, sandbox/test instances): the canonical `recipe-upstream` script in
`references/recipe-maintainer/.claude/commands/recipe-upstream.md` runs as written. Note its
WORKSPACE probing expects `/workspace` or `~/Documents/recipe-maintainer`; on a bare host pass
the submodule dir explicitly instead.
- **Non-sandboxed** (no ARM env on the host — e.g. the orchestrator, where the recipe-maintainer
checkout is only a pinned submodule): no sandbox/test instances are needed and NONE of the
setup skills are. Recipe-maintainer mirrors on `git.autonomic.zone` are publicly readable, so:
1. Check out the recipe if missing: `abra recipe fetch <recipe>` (lands in `~/.abra/recipes/<recipe>`)
— or a plain anonymous `git clone https://git.autonomic.zone/recipe-maintainers/<recipe>.git`
if abra is unavailable.
2. Fetch the PR head branch from the mirror **anonymously** — no credentials in the remote URL:
`git remote add gitea https://git.autonomic.zone/recipe-maintainers/<recipe>.git`
(remote update rather than re-add if it exists), then
`git fetch gitea +refs/pull/<N>/head:refs/heads/<head_ref>`.
3. Fetch PR metadata (head/base refs, merged flag, release bump line) from
`https://git.autonomic.zone/api/v1/repos/recipe-maintainers/<recipe>/pulls/<N>` —
unauthenticated; use bot creds only if the repo turns out to be private (orchestrator hosts
can read them from `/srv/cc-ci-orch/.testenv` — never written anywhere else).
4. Everything else in the canonical script (origin/dev remote setup, release recommendation,
emitted next-steps) is identical.
Every time the branch was prepared **here**, remember it exists only on this host — the
operator's machine must fetch it first. Always emit this **step 0** before the push step
(anonymous public fetch, no credentials needed):
```
# 0. On a machine WITHOUT the branch pre-fetched, get it from the autonomic mirror
# (fetch by URL — works regardless of what the local remotes are named):
cd <local checkout of the recipe>
git fetch https://git.autonomic.zone/recipe-maintainers/<recipe>.git +refs/pull/<PR_NUM>/head:refs/heads/<HEAD_REF>
git checkout <HEAD_REF>
```
If the operator's checkout does NOT yet have the mirror remote, emit once before the fetch:
```
git remote add gitea https://git.autonomic.zone/recipe-maintainers/<recipe>.git
```
In both modes the final output is a set of commands for the operator to run on a machine **with
push access to `git.coopcloud.tech`** — always print them, even when everything local is
already prepared.
**History-divegence check before emitting the compare URL.** The autonomic recipe-maintainer
mirrors' `main` can share only a distant ancestor with upstream `main` (same work re-committed
with different hashes as the mirror was synced/rebuilt). A 3-dot compare
`main...<head_ref>` from the raw mirror branch then shows the ENTIRE diverged history, not the
one upgrade commit. Guard: while preparing, run
`git merge-base <head_ref> <base_ref>`; if the result is older than a few commits below upstream
`origin/main`, cherry-pick the upgrade commit(s) onto fresh upstream `origin/main` (branch
`<head_ref>-rebased`), push THAT to the autonomic mirror, and emit the fetch/push/compare commands
using the rebased branch instead of the raw one. Also fix the stale `origin/main` wording above:
the upstream base branch may be `master`, not `main` — derive it from the remotes.
**Branch-name mismatch mirror vs upstream.** The mirror and upstream can use different base-branch
names (gitea: mirror `main`, upstream `master`). When emitting step 3 (the post-merge release),
NEVER hardcode `main` — derive the upstream default branch from the existing remotes (`git
remote show origin` or `git ls-remote --symref origin HEAD`) and emit `git checkout <that>;
git fetch origin; git merge --ff-only origin/<that>;` before `abra recipe release`.
""",
}
WRAPPER_TEMPLATE = """# {wrapped} (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/{wrapped}/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/{name}/SKILL.md`.
"""
def parse_frontmatter(text: str) -> dict[str, str]:
m = re.match(r"\A---\n(.*?)\n---\n", text, re.S)
fields: dict[str, str] = {}
if m:
for line in m.group(1).splitlines():
if ":" in line:
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
return fields
def main() -> int:
if not ARM_SKILLS.is_dir():
print(f"ERROR: {ARM_SKILLS} missing — init the submodule first", file=sys.stderr)
return 1
arm_names = sorted(p.parent.name for p in ARM_SKILLS.glob("*/SKILL.md"))
written, removed = [], []
for pos in (ROOT / ".opencode/skills", ROOT / ".claude/skills"):
for stale in pos.glob(f"{PREFIX}*"):
if stale.name[len(PREFIX) :] not in arm_names:
shutil.rmtree(stale)
removed.append(str(stale.relative_to(ROOT)))
for name in arm_names:
src = ARM_SKILLS / name / "SKILL.md"
fm = parse_frontmatter(src.read_text())
desc = fm.get("description", f"ARM skill {name} (no description)")
wrapped = PREFIX + name
frontmatter = (
f"---\nname: {wrapped}\n"
f"description: \"[recipe-maintainer/cctest] {desc} (Wraps the autonomic-recipe-"
f"maintainer skill /{name}; runs against the cctest test server + ARM sandbox, "
f"not cc-ci. Invoke as /{wrapped}.)\"\n---\n\n"
)
canon = ROOT / ".opencode/skills" / wrapped / "SKILL.md"
canon.parent.mkdir(parents=True, exist_ok=True)
canon.write_text(frontmatter + BODY_TEMPLATE.format(name=name, wrapped=wrapped) + PER_SKILL_NOTES.get(name, ""))
thin = ROOT / ".claude/skills" / wrapped / "SKILL.md"
thin.parent.mkdir(parents=True, exist_ok=True)
thin.write_text(frontmatter + WRAPPER_TEMPLATE.format(name=name, wrapped=wrapped))
written.append(wrapped)
print(f"generated {len(written)} cctest skills: {', '.join(written)}")
if removed:
print(f"removed stale: {', '.join(removed)}")
return 0
if __name__ == "__main__":
sys.exit(main())