From a1ee9bec20b5a7ca695d68bb5c5258dbf79c1f62 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Mon, 9 Mar 2026 18:18:47 +0100 Subject: [PATCH] chore(repo): add publishing infrastructure --- .github/workflows/skills-ci.yml | 34 ++++++ .github/workflows/skills-release.yml | 164 +++++++++++++++++++++++++++ .release-policy.yml | 1 + LICENSE | 21 ++++ README.md | 65 +++++++++++ scripts/build_release_notes.py | 57 ++++++++++ scripts/compute_next_version.py | 68 +++++++++++ scripts/validate_skills.py | 103 +++++++++++++++++ 8 files changed, 513 insertions(+) create mode 100644 .github/workflows/skills-ci.yml create mode 100644 .github/workflows/skills-release.yml create mode 100644 .release-policy.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 scripts/build_release_notes.py create mode 100755 scripts/compute_next_version.py create mode 100755 scripts/validate_skills.py diff --git a/.github/workflows/skills-ci.yml b/.github/workflows/skills-ci.yml new file mode 100644 index 0000000..71445d1 --- /dev/null +++ b/.github/workflows/skills-ci.yml @@ -0,0 +1,34 @@ +name: skills-ci + +on: + push: + paths: + - 'skills/**' + - 'scripts/**' + - '.github/workflows/skills-*.yml' + pull_request: + paths: + - 'skills/**' + - 'scripts/**' + - '.github/workflows/skills-*.yml' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Validate skills + run: python scripts/validate_skills.py + - name: Smoke discovery + run: | + if [ -d skills ] && find skills -name SKILL.md -print -quit | grep -q .; then + npx --yes skills add . --list + else + echo "No skills present yet; skipping discovery smoke test." + fi diff --git a/.github/workflows/skills-release.yml b/.github/workflows/skills-release.yml new file mode 100644 index 0000000..e2ee36a --- /dev/null +++ b/.github/workflows/skills-release.yml @@ -0,0 +1,164 @@ +name: skills-release + +on: + push: + branches: [main] + paths: + - 'skills/**' + - 'scripts/**' + - '.release-policy.yml' + - '.github/workflows/skills-release.yml' + workflow_dispatch: + inputs: + version_override: + description: 'Optional explicit version tag (vX.Y.Z)' + required: false + type: string + bump_type: + description: 'Semver bump type when no version_override is set' + required: false + default: 'patch' + type: choice + options: + - patch + - minor + - major + - auto + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate skills + run: python scripts/validate_skills.py + + - name: Read release policy + id: policy + run: | + python - << 'PY' + import re + from pathlib import Path + + txt = Path('.release-policy.yml').read_text(encoding='utf-8') + m = re.search(r"(?im)^\s*auto_release\s*:\s*(true|false)\s*(?:#.*)?$", txt) + auto = bool(m and m.group(1).lower() == 'true') + with open(Path.cwd() / '.policy_out', 'w', encoding='utf-8') as f: + f.write(f"auto_release={'true' if auto else 'false'}\n") + PY + cat .policy_out >> "$GITHUB_OUTPUT" + + - name: Decide whether release is allowed + id: gate + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.policy.outputs.auto_release }}" = "true" ]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + else + echo "should_release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Stop (policy gate) + if: steps.gate.outputs.should_release != 'true' + run: echo "Release policy disabled for push events; exiting successfully." + + - name: Determine bump from PR labels + if: steps.gate.outputs.should_release == 'true' && github.event_name == 'push' + id: prlabels + uses: actions/github-script@v7 + with: + script: | + const {owner, repo} = context.repo; + let bump = 'patch'; + try { + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: context.sha, + }); + if (prs.data && prs.data.length > 0) { + const labels = (prs.data[0].labels || []).map(l => l.name); + if (labels.includes('release:major')) bump = 'major'; + else if (labels.includes('release:minor')) bump = 'minor'; + } + } catch (e) { + core.warning(`Could not infer PR labels: ${e.message}`); + } + core.setOutput('bump', bump); + + - name: Compute version + if: steps.gate.outputs.should_release == 'true' + id: version + env: + INPUT_BUMP: ${{ github.event.inputs.bump_type }} + INPUT_VERSION: ${{ github.event.inputs.version_override }} + PUSH_BUMP: ${{ steps.prlabels.outputs.bump }} + run: | + if [ -n "${INPUT_VERSION:-}" ]; then + VERSION="$INPUT_VERSION" + else + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + BUMP="${INPUT_BUMP:-patch}" + else + BUMP="${PUSH_BUMP:-patch}" + fi + VERSION=$(python scripts/compute_next_version.py --bump "$BUMP") + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check tag does not already exist + if: steps.gate.outputs.should_release == 'true' + run: | + if git rev-parse "${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "Tag ${{ steps.version.outputs.version }} already exists" >&2 + exit 1 + fi + + - name: Determine release range + if: steps.gate.outputs.should_release == 'true' + id: range + run: | + PREV=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true) + if [ -n "$PREV" ]; then + FROM="$PREV" + else + FROM=$(git hash-object -t tree /dev/null) + fi + echo "from_ref=$FROM" >> "$GITHUB_OUTPUT" + echo "to_ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Build release notes + if: steps.gate.outputs.should_release == 'true' + run: | + python scripts/build_release_notes.py \ + --from-ref "${{ steps.range.outputs.from_ref }}" \ + --to-ref "${{ steps.range.outputs.to_ref }}" \ + --version "${{ steps.version.outputs.version }}" \ + --repo "${{ github.repository }}" \ + --output RELEASE_NOTES.md + + - name: Create and push tag + if: steps.gate.outputs.should_release == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "${{ steps.version.outputs.version }}" "${GITHUB_SHA}" + git push origin "${{ steps.version.outputs.version }}" + + - name: Create GitHub release + if: steps.gate.outputs.should_release == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} + body_path: RELEASE_NOTES.md diff --git a/.release-policy.yml b/.release-policy.yml new file mode 100644 index 0000000..574cc56 --- /dev/null +++ b/.release-policy.yml @@ -0,0 +1 @@ +auto_release: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95ee507 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gecode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2d30ef --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Gecode Skills + +Repository infrastructure for publishing Gecode-focused AI agent skills. + +This branch sets up validation, CI, and release automation. It does not introduce a published skill yet. + +## Infrastructure + +Included here: + +- GitHub Actions for validation and release publishing +- semver/version helper scripts +- release policy gating through `.release-policy.yml` +- generic skill validation that works before any skill is added + +## Future Skill Layout + +Skills will live under: + +- `skills//SKILL.md` + +## Contributing + +### Skill structure + +Each skill must be under: + +- `skills//SKILL.md` + +Optional metadata for UIs can be added at: + +- `skills//agents/openai.yaml` + +### Required frontmatter + +Each `SKILL.md` must include YAML frontmatter with: + +- `name` +- `description` + +The `name` must match the directory name (``). + +### Release bump labels + +Auto-release determines semver bump from PR labels: + +- `release:major` -> major bump +- `release:minor` -> minor bump +- no label -> patch bump + +## Release Policy + +Releases are controlled by `.release-policy.yml`. + +- Initially: `auto_release: false` +- This means pushes to `main` do **not** auto-release. +- Manual release via workflow dispatch is enabled. + +To enable auto-release later, set: + +```yaml +auto_release: true +``` + +and merge that change with maintainer review. diff --git a/scripts/build_release_notes.py b/scripts/build_release_notes.py new file mode 100755 index 0000000..91c1d86 --- /dev/null +++ b/scripts/build_release_notes.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path + + +def changed_skills(diff_range: str) -> list[str]: + out = subprocess.check_output(["git", "diff", "--name-only", diff_range], text=True) + names: set[str] = set() + for line in out.splitlines(): + parts = line.split("/") + if len(parts) >= 3 and parts[0] == "skills" and parts[1].startswith("gecode-"): + names.add(parts[1]) + return sorted(names) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--from-ref", required=True) + ap.add_argument("--to-ref", required=True) + ap.add_argument("--version", required=True) + ap.add_argument("--repo", required=True, help="owner/repo") + ap.add_argument("--output", required=True) + args = ap.parse_args() + + diff_range = f"{args.from_ref}..{args.to_ref}" + skills = changed_skills(diff_range) + + lines = [] + lines.append(f"# {args.version}") + lines.append("") + if skills: + lines.append("## Changed skills") + lines.append("") + for s in skills: + lines.append(f"- `{s}`") + lines.append("") + else: + lines.append("No skill directory changes detected in this release range.") + lines.append("") + + lines.append("## Install") + lines.append("") + lines.append(f"```bash\nnpx skills add {args.repo}\n```") + lines.append("") + lines.append("List available skills:") + lines.append("") + lines.append(f"```bash\nnpx skills add {args.repo} --list\n```") + + Path(args.output).write_text("\n".join(lines) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compute_next_version.py b/scripts/compute_next_version.py new file mode 100755 index 0000000..95648bb --- /dev/null +++ b/scripts/compute_next_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +import sys + +TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def latest_tag() -> tuple[int, int, int]: + out = subprocess.check_output( + ["git", "tag", "--list", "v*", "--sort=-v:refname"], text=True + ).strip() + if not out: + return (0, 0, 0) + for tag in out.splitlines(): + m = TAG_RE.match(tag.strip()) + if m: + return tuple(int(m.group(i)) for i in (1, 2, 3)) + return (0, 0, 0) + + +def bump_from_labels(labels: list[str]) -> str: + s = set(labels) + if "release:major" in s: + return "major" + if "release:minor" in s: + return "minor" + return "patch" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--bump", choices=["major", "minor", "patch", "auto"], default="auto") + ap.add_argument("--labels", default="") + ap.add_argument("--current", default="") + args = ap.parse_args() + + if args.current: + m = TAG_RE.match(args.current.strip()) + if not m: + print("ERROR: --current must be in form vX.Y.Z", file=sys.stderr) + return 1 + cur = (int(m.group(1)), int(m.group(2)), int(m.group(3))) + else: + cur = latest_tag() + + bump = args.bump + if bump == "auto": + labels = [x.strip() for x in args.labels.split(",") if x.strip()] + bump = bump_from_labels(labels) + + major, minor, patch = cur + if bump == "major": + nxt = (major + 1, 0, 0) + elif bump == "minor": + nxt = (major, minor + 1, 0) + else: + nxt = (major, minor, patch + 1) + + print(f"v{nxt[0]}.{nxt[1]}.{nxt[2]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py new file mode 100755 index 0000000..6481696 --- /dev/null +++ b/scripts/validate_skills.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILLS_DIR = REPO_ROOT / "skills" + + +def parse_frontmatter(skill_md: Path) -> dict[str, str]: + text = skill_md.read_text(encoding="utf-8") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + raise ValueError("missing opening frontmatter delimiter '---'") + + end = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end = i + break + if end is None: + raise ValueError("missing closing frontmatter delimiter '---'") + + fm = {} + kv_re = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$") + for line in lines[1:end]: + if not line.strip() or line.lstrip().startswith("#"): + continue + m = kv_re.match(line) + if not m: + continue + key, raw_val = m.group(1), m.group(2).strip() + if raw_val.startswith('"') and raw_val.endswith('"') and len(raw_val) >= 2: + raw_val = raw_val[1:-1] + if raw_val.startswith("'") and raw_val.endswith("'") and len(raw_val) >= 2: + raw_val = raw_val[1:-1] + fm[key] = raw_val + return fm + + +def main() -> int: + errors: list[str] = [] + seen_names: dict[str, Path] = {} + validated = 0 + + if not SKILLS_DIR.exists(): + print("No skills directory found; validation succeeded with 0 skills.") + return 0 + + skill_dirs = sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir()) + if not skill_dirs: + print("No skill directories found; validation succeeded with 0 skills.") + return 0 + + for skill_dir in skill_dirs: + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + errors.append(f"{skill_dir}: missing SKILL.md") + continue + + try: + fm = parse_frontmatter(skill_md) + except ValueError as e: + errors.append(f"{skill_md}: {e}") + continue + + for req in ("name", "description"): + if req not in fm or not fm[req].strip(): + errors.append(f"{skill_md}: missing required frontmatter field '{req}'") + + name = fm.get("name", "") + if name and name != skill_dir.name: + errors.append( + f"{skill_md}: frontmatter name '{name}' does not match directory '{skill_dir.name}'" + ) + + if name: + if name in seen_names: + errors.append( + f"duplicate skill name '{name}' in {skill_md} and {seen_names[name]}" + ) + else: + seen_names[name] = skill_md + validated += 1 + + agents_dir = skill_dir / "agents" + if agents_dir.exists() and not (agents_dir / "openai.yaml").exists(): + errors.append(f"{skill_dir}: agents/ exists but agents/openai.yaml is missing") + + if errors: + print("Skill validation failed:") + for e in errors: + print(f"- {e}") + return 1 + + noun = "skill" if validated == 1 else "skills" + print(f"Validated {validated} {noun} successfully.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())