183 lines
9.0 KiB
Python
183 lines
9.0 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.
|
|
|
|
**Verify the merge-base BEFORE rebasing — bases can be traps.** Upstream repos can
|
|
carry a stray, divergent `main` ALONGSIDE the real `master` base branch. Rebase targets must be
|
|
chosen by evidence: `git ls-remote origin | grep -E 'refs/heads/(main|master)$'`, then
|
|
`git merge-base <head_ref> origin/<candidate>` — the correct base is the one where the upgrade
|
|
branch's merge-base is its tip's parent (i.e. `<head_ref>` is already directly on that line —
|
|
then NO cherry-pick is needed, the raw branch compares clean). Cherry-picking onto the wrong
|
|
base produces the very messy 3-dot compare this guard exists to prevent (real case: gitea —
|
|
first "rebase" landed on a stray `main` and had to be redone onto `master`).
|
|
|
|
**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())
|