diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..77d540b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +--- +# Auto-tag + GitHub Release from CHANGELOG.md. +# +# Trigger: every push to main. Parses CHANGELOG.md, takes the latest version +# entry, and creates a git tag (v) + GitHub Release with notes +# extracted from that section. Idempotent — if the tag already exists, the +# workflow skips with a notice. Manual re-runs via workflow_dispatch can +# target a specific version. +# +# CHANGELOG.md header format expected: +# ## [X.Y.Z] - YYYY-MM-DD — optional subtitle +# +# To cut a new release: add a new version entry to the top of CHANGELOG.md +# and push to main. The workflow will fire on that push and create the tag. + +name: Release + +'on': + push: + branches: [main] + paths: + - 'CHANGELOG.md' + - '.github/workflows/release.yml' + - 'scripts/extract_release_notes.py' + workflow_dispatch: + inputs: + version: + description: 'Specific version to release (e.g. 2.8.0). Default: latest in CHANGELOG.' + required: false + +permissions: + contents: write # needed to push tag + create release + +jobs: + release: + name: Tag + GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history to push tags + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Parse target version from CHANGELOG + id: parse + run: | + set -euo pipefail + if [[ -n "${{ github.event.inputs.version }}" ]]; then + VERSION="${{ github.event.inputs.version }}" + python3 scripts/extract_release_notes.py --version "$VERSION" --format json > /tmp/release.json + else + python3 scripts/extract_release_notes.py --format json > /tmp/release.json + VERSION=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['version'])") + fi + DATE=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['date'])") + SUBTITLE=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['subtitle'])") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "date=$DATE" >> "$GITHUB_OUTPUT" + echo "subtitle=$SUBTITLE" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "Parsed: v$VERSION ($DATE) — $SUBTITLE" + + - name: Check if tag already exists + id: tagcheck + run: | + set -euo pipefail + TAG="${{ steps.parse.outputs.tag }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists locally." + elif git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists on remote." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag $TAG does not exist — will create." + fi + + - name: Prepare release body + if: steps.tagcheck.outputs.exists == 'false' + run: | + python3 scripts/extract_release_notes.py \ + --version "${{ steps.parse.outputs.version }}" \ + --format github-release > /tmp/release-body.md + echo "=== Release body preview ===" + head -50 /tmp/release-body.md + + - name: Create and push tag + if: steps.tagcheck.outputs.exists == 'false' + run: | + set -euo pipefail + TAG="${{ steps.parse.outputs.tag }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG — ${{ steps.parse.outputs.subtitle }}" + git push origin "$TAG" + + - name: Create GitHub Release + if: steps.tagcheck.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.parse.outputs.tag }}" + SUBTITLE="${{ steps.parse.outputs.subtitle }}" + if [[ -n "$SUBTITLE" ]]; then + TITLE="$TAG — $SUBTITLE" + else + TITLE="$TAG" + fi + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-body.md \ + --verify-tag + + - name: Skip (tag exists) + if: steps.tagcheck.outputs.exists == 'true' + run: | + echo "::notice::Tag ${{ steps.parse.outputs.tag }} already exists. Skipping release creation." diff --git a/CHANGELOG.md b/CHANGELOG.md index 47336631..3ff3febb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ All notable changes to the Claude Skills Library will be documented in this file The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.8.0] - 2026-05-19 — business-operations + commercial domains, plugin.json regression fix, auto-release pipeline + +### Added + +#### `business-operations/` — new top-level domain (Sprint 1) + +Internal-ops skills for BizOps leads, COO direct reports, vendor management, IT ops. Sprint 1 ships the orchestrator + 2 sub-skills using `context: fork` to chain without polluting parent context. Sprint 2 will add `capacity-planner`, `internal-comms`, `knowledge-ops`, `procurement-optimizer`. + +- `business-operations-skills` (orchestrator) — routes inquiries to the right sub-skill and returns a digest +- `process-mapper` — BPMN modeling + bottleneck detection + cycle-time analysis +- `vendor-management` — SLA tracking + risk scoring + supplier scorecards +- Distinct from `business-growth/` (external sales) and `c-level-advisor/` (strategic, not operational) + +#### `commercial/` — new top-level domain (Sprint 1) + +Per-deal economics skills for pricing, deal desk, partnerships. Sprint 1 ships the orchestrator + 2 sub-skills. Sprint 2 will add `partnerships-architect`, `channel-economics`, `commercial-policy`, `rfp-responder`, `commercial-forecaster`. + +- `commercial-skills` (orchestrator) — routes commercial inquiries via `context: fork` +- `pricing-strategist` — Van Westendorp WTP analysis + packaging + pricing-model picker +- `deal-desk` — margin analysis + discount routing + contract redline scoring +- Distinct from `business-growth/sales-engineer`, `c-level-advisor/cro-advisor`, `finance/financial-analysis` + +#### Forcing-question slash commands + +- `/cs:grill-bizops` — Matt Pocock docs-anchored grilling for BizOps workflows +- `/cs:grill-commercial` — same for commercial decisions (pricing, deals, partnerships) + +#### Release automation + +- **`.github/workflows/release.yml`** — On every push to `main`, parses CHANGELOG.md and auto-creates a git tag + GitHub Release for the latest version listed. Idempotent (skips if tag already exists). Release notes are extracted from the matching CHANGELOG section. +- **`scripts/extract_release_notes.py`** — Stdlib-only CHANGELOG parser. Extracts the latest version, date, subtitle, and body. Used by the release workflow but also runnable standalone for previewing release notes. + +### Fixed + +#### Plugin manifest `/doctor` warning — issue #686 (reported by @esoneill) + +Claude Code 2.1.133+ rejects `"skills": "./skills"` with a "Path escapes plugin directory" warning, even though `./skills` resolves to a valid subdirectory inside the plugin root. This blocked skill registration for the 9 main marketplace plugins plus 38 sibling sub-plugins. + +- **PR #689** — replaced `"skills": "./skills"` with `"skills": "skills"` across all 47 affected `plugin.json` files. Updated `CLAUDE.md` ClawHub publishing constraints to document the new convention. +- **PR #690 (regression prevention)** — `scripts/check_plugin_json.py` now actively rejects any `"skills"` string starting with `"./"` (catches both `"./skills"` and `"./skills/sub"` regressions). Wired into `ci-quality-gate.yml` as a blocking step on every PR. Also recognized `source` and `attribution` as approved extension fields (per CLAUDE.md), and dropped the over-strict `"./"` rejection inside arrays (`["./"]` is the documented single-skill-at-root form). Validator's previous error message was actually recommending `"./skills"` verbatim — a leftover from #539, the *first* round of this same upstream rule tightening — which has been corrected. + +This is the second round of the same Claude Code path-validator tightening (round 1 was #539, fixing `"./"` → `"./skills"` at CC v2.1.107). The new validator + CI gate prevents a future round 3 from silently shipping again. + +### Maintenance + +- **inspect-assets.py** (#684, contributor: @TemaDeveloper) — `--help` now works without Pillow installed +- Codex symlink syncs (automated) + +### Stats + +- 313 → 319 skills (business-operations: +3, commercial: +3) +- 12 → 14 top-level domains +- 60 → 68 slash commands + ## [2.7.3] - 2026-05-17 — aeo-box port: AEO skill + security-guidance PreToolUse hook ### Added diff --git a/scripts/extract_release_notes.py b/scripts/extract_release_notes.py new file mode 100755 index 00000000..8c146923 --- /dev/null +++ b/scripts/extract_release_notes.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Extract release notes for a specific version (or the latest) from CHANGELOG.md. + +CHANGELOG.md must follow the Keep-a-Changelog header format: + ## [X.Y.Z] - YYYY-MM-DD - optional subtitle + +Outputs JSON by default: + {"version": "2.8.0", "date": "2026-05-19", "subtitle": "...", "body": "..."} + +Or use --format=plain to print just the body, or --format=github-release to +print a release body suitable for `gh release create --notes-file -`. + +Examples: + python3 scripts/extract_release_notes.py + python3 scripts/extract_release_notes.py --version 2.7.3 + python3 scripts/extract_release_notes.py --format plain + python3 scripts/extract_release_notes.py --format github-release +""" +import argparse +import json +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +CHANGELOG = REPO / "CHANGELOG.md" + +# Matches: ## [2.8.0] - 2026-05-19 - optional subtitle text +# Subtitle separator can be "-", "—", or absent. +HEADER = re.compile( + r"^##\s+\[(?P\d+\.\d+\.\d+(?:-[\w.]+)?)\]\s*-\s*" + r"(?P\d{4}-\d{2}-\d{2})" + r"(?:\s*[-—]\s*(?P.+))?\s*$" +) + + +def parse_changelog(text): + """Return list of {version, date, subtitle, body} dicts in file order.""" + lines = text.splitlines() + entries = [] + current = None + body_lines = [] + for line in lines: + m = HEADER.match(line) + if m: + if current: + current["body"] = "\n".join(body_lines).strip() + entries.append(current) + current = { + "version": m.group("version"), + "date": m.group("date"), + "subtitle": (m.group("subtitle") or "").strip(), + } + body_lines = [] + elif current is not None: + body_lines.append(line) + if current: + current["body"] = "\n".join(body_lines).strip() + entries.append(current) + return entries + + +def select_entry(entries, version): + if version is None: + if not entries: + return None + return entries[0] + for e in entries: + if e["version"] == version: + return e + return None + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--version", help="Specific version to extract (default: latest)") + ap.add_argument("--changelog", default=str(CHANGELOG), help="Path to CHANGELOG.md") + ap.add_argument( + "--format", + choices=["json", "plain", "github-release"], + default="json", + help="Output format (default: json)", + ) + args = ap.parse_args() + + path = Path(args.changelog) + if not path.exists(): + print(f"CHANGELOG not found: {path}", file=sys.stderr) + return 2 + + entries = parse_changelog(path.read_text()) + entry = select_entry(entries, args.version) + if not entry: + if args.version: + print(f"Version {args.version} not found in {path}", file=sys.stderr) + else: + print(f"No version entries found in {path}", file=sys.stderr) + return 1 + + if args.format == "json": + print(json.dumps(entry, indent=2)) + elif args.format == "plain": + print(entry["body"]) + elif args.format == "github-release": + title_line = f"# {entry['version']} — {entry['subtitle']}" if entry["subtitle"] else f"# {entry['version']}" + print(title_line) + print(f"_Released {entry['date']}_") + print() + print(entry["body"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main())