feat(release): auto-tag + GitHub Release from CHANGELOG on push to main

Adds end-to-end release automation so every CHANGELOG bump produces a
matching git tag + GitHub Release with notes — no manual `gh release
create` invocations required.

- .github/workflows/release.yml — push to main triggers parse-CHANGELOG
  → check tag exists → create tag → create GH Release with notes. Idempotent
  (existing tags skipped). Manual workflow_dispatch supports targeting a
  specific historical version. Path-filter limits firing to actual release
  pushes (CHANGELOG.md, the parser, or the workflow itself changing).

- scripts/extract_release_notes.py — stdlib-only CHANGELOG.md parser.
  Outputs JSON, plain text, or github-release-formatted markdown.
  Runnable standalone for preview: `python3 scripts/extract_release_notes.py
  --format github-release`. Default extracts the latest entry; --version
  pins to a specific release.

- CHANGELOG.md — new [2.8.0] entry covering the v2.8.0 Sprint 1 work
  (business-operations + commercial domains, #688) plus the #686 plugin.json
  fix (#689) and #690 regression-prevention validator + CI gate. This is the
  entry the release workflow will pick up on first run after this lands on
  main.

When this commit (plus the dev → main sync #692) reaches main, the workflow
fires, parses CHANGELOG, sees v2.8.0 at the top, creates the `v2.8.0` tag
and GitHub Release — automatically. All future releases follow the same
flow: add a CHANGELOG entry, merge to main, done.
This commit is contained in:
Claude 2026-05-19 04:00:06 +00:00
parent f7bb1f86bc
commit d34e615b73
No known key found for this signature in database
3 changed files with 295 additions and 0 deletions

125
.github/workflows/release.yml vendored Normal file
View file

@ -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<version>) + 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."

View file

@ -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

116
scripts/extract_release_notes.py Executable file
View file

@ -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<version>\d+\.\d+\.\d+(?:-[\w.]+)?)\]\s*-\s*"
r"(?P<date>\d{4}-\d{2}-\d{2})"
r"(?:\s*[-—]\s*(?P<subtitle>.+))?\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())