Skip to content

Commit de3d434

Browse files
authored
Add semantic versioning to workflows and shared files (#60)
* Add version: 0.1.0 frontmatter to all workflows and shared files Add a semver version field to all 13 SKILL.md files and add YAML frontmatter (name + version) to the 3 _shared/ files. All start at 0.1.0 per the pre-1.0 convention for actively evolving workflows. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add validate-versions.sh and CI job Diff-based CI check that validates workflow version bumps when behavioral files change. Handles merge commits by comparing against the first parent, traces shared file cascades including transitive references, and validates semver format on all versioned files. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add version checks to pre-review-checks.py Validate the version field in SKILL.md frontmatter (semver format) alongside existing name/description checks. Add shared file frontmatter validation for _shared/**/*.md files (name and version required). Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add auto-tagging workflow for version bumps Creates git tags automatically when version bumps are merged to main. Tags follow {workflow}/v{version} and _shared/{name}/v{version} conventions. Handles merge commits, checks for existing tags, and pushes all new tags in a single operation. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Document workflow versioning in AGENTS.md, CONTRIBUTING.md, CLAUDE.md Add versioning rules for AI assistants (AGENTS.md), developer contribution guidelines (CONTRIBUTING.md), and a pointer from CLAUDE.md. Update SKILL.md frontmatter examples to include the version field. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Validate shared file version bumps, not just cascade The validation script checked that consuming workflows bumped their versions when a shared file changed, but did not check that the shared file itself had its version bumped. Add that check. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address code review feedback - Remove redundant _shared/*.md case pattern in validate-versions.sh - Add concurrency control to tag-versions.yaml (serialize tag creation) - Scope contents:write to job level with explanatory comment - Add persist-credentials to tag-versions checkout step - Fix grep patterns in docs to use basename (matches actual references) - Convert indented code blocks to fenced (MD046) - Add commit convention and shared file tag format to CONTRIBUTING.md Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Extract frontmatter parsing to module-level function Move duplicated YAML frontmatter parsing logic from Checker._parse_frontmatter() and RepoChecker.check_shared_frontmatter() into a shared parse_frontmatter() function. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix case pattern to match top-level _shared/*.md files Bash case statements don't support ** as glob-recursive. The pattern _shared/**/*.md only matches nested files like _shared/recipes/*.md, not top-level files like _shared/review-protocol.md. Use explicit patterns for both levels. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Reject frontmatter without closing delimiter parse_frontmatter() now returns None if the file starts with --- but never has a matching closing ---. Previously it would parse the entire file as frontmatter fields, producing false positives. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address Amir's review feedback - Remove dangling versioning.md reference from CONTRIBUTING.md; replace with inline rationale (versioning.md was a planning doc, not intended to be committed) - Reorder validate-versions.sh: run static semver format checks (sections 5-6) before arithmetic comparisons (sections 8, 10) to avoid bash errors on malformed version strings - Push only newly created tags in tag-versions.yaml instead of --tags which pushes all local tags Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b4c815d commit de3d434

23 files changed

Lines changed: 567 additions & 19 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#!/usr/bin/env bash
2+
# Validates that workflow versions are bumped when behavioral files change.
3+
# Run in CI via .github/workflows/lint.yaml.
4+
#
5+
# On PRs: compares the branch against the merge base with the target branch.
6+
# On push to main: compares HEAD against its first parent (handles merge commits).
7+
# Locally: compares against origin/main.
8+
9+
set -euo pipefail
10+
11+
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
12+
cd "$REPO_ROOT"
13+
14+
errors=0
15+
warnings=0
16+
17+
fail() {
18+
echo "FAIL: $1"
19+
errors=$((errors + 1))
20+
}
21+
22+
warn() {
23+
echo "WARN: $1"
24+
warnings=$((warnings + 1))
25+
}
26+
27+
info() {
28+
echo "INFO: $1"
29+
}
30+
31+
# ---------------------------------------------------------------------------
32+
# 1. Determine base ref
33+
# ---------------------------------------------------------------------------
34+
if [ -n "${GITHUB_BASE_REF:-}" ]; then
35+
BASE_REF="origin/${GITHUB_BASE_REF}"
36+
elif [ "${GITHUB_EVENT_NAME:-}" = "push" ]; then
37+
BASE_REF=$(git rev-parse HEAD^1)
38+
else
39+
BASE_REF="origin/main"
40+
fi
41+
42+
MERGE_BASE=$(git merge-base "$BASE_REF" HEAD 2>/dev/null || echo "$BASE_REF")
43+
info "Base ref: $BASE_REF"
44+
info "Merge base: $MERGE_BASE"
45+
46+
# ---------------------------------------------------------------------------
47+
# 2. Get changed files
48+
# ---------------------------------------------------------------------------
49+
mapfile -t CHANGED_FILES < <(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null \
50+
|| git diff --name-only "$MERGE_BASE" HEAD)
51+
52+
if [ ${#CHANGED_FILES[@]} -eq 0 ]; then
53+
info "No changed files detected"
54+
exit 0
55+
fi
56+
57+
# ---------------------------------------------------------------------------
58+
# 3. Semver comparison: returns 0 if $1 < $2
59+
# ---------------------------------------------------------------------------
60+
semver_lt() {
61+
local IFS='.'
62+
read -r a1 a2 a3 <<< "$1"
63+
read -r b1 b2 b3 <<< "$2"
64+
if [ "$a1" -lt "$b1" ]; then return 0; fi
65+
if [ "$a1" -gt "$b1" ]; then return 1; fi
66+
if [ "$a2" -lt "$b2" ]; then return 0; fi
67+
if [ "$a2" -gt "$b2" ]; then return 1; fi
68+
if [ "$a3" -lt "$b3" ]; then return 0; fi
69+
return 1
70+
}
71+
72+
# ---------------------------------------------------------------------------
73+
# 4. Behavioral file detection
74+
# ---------------------------------------------------------------------------
75+
is_behavioral() {
76+
local file="$1"
77+
case "$file" in
78+
*/skills/*.md|*/commands/*.md|*/guidelines.md)
79+
return 0 ;;
80+
*/templates/*|*/prompts/*|*/scripts/*)
81+
return 0 ;;
82+
_shared/*.md|_shared/*/*.md)
83+
return 0 ;;
84+
*/SKILL.md)
85+
local body_diff
86+
body_diff=$(git diff "$MERGE_BASE"...HEAD -- "$file" 2>/dev/null \
87+
|| git diff "$MERGE_BASE" HEAD -- "$file")
88+
body_diff=$(echo "$body_diff" \
89+
| grep '^[+-]' \
90+
| grep -v '^[+-]version:' \
91+
| grep -v '^[+-]---' \
92+
| grep -v '^[+-][+-][+-]' || true)
93+
[ -n "$body_diff" ] && return 0
94+
return 1 ;;
95+
*/README.md|*/GUIDE.md)
96+
return 1 ;;
97+
*/*)
98+
local dir
99+
dir=$(dirname "$file")
100+
if [ -f "$dir/SKILL.md" ] && [[ "$file" == *.md ]]; then
101+
return 0
102+
fi
103+
return 1 ;;
104+
*)
105+
return 1 ;;
106+
esac
107+
}
108+
109+
# ---------------------------------------------------------------------------
110+
# 5. Static: all SKILL.md files must have valid semver
111+
# ---------------------------------------------------------------------------
112+
for skill in */SKILL.md; do
113+
[ -f "$skill" ] || continue
114+
wf=$(dirname "$skill")
115+
ver=$(sed -n 's/^version: *//p' "$skill")
116+
if [ -z "$ver" ]; then
117+
fail "$wf: SKILL.md missing version field"
118+
elif ! echo "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
119+
fail "$wf: version '$ver' is not valid semver (expected X.Y.Z)"
120+
fi
121+
done
122+
123+
# ---------------------------------------------------------------------------
124+
# 6. Static: all _shared/*.md files must have valid frontmatter with version
125+
# ---------------------------------------------------------------------------
126+
while IFS= read -r -d '' shared; do
127+
if ! head -1 "$shared" | grep -q '^---$'; then
128+
fail "$shared: missing YAML frontmatter"
129+
continue
130+
fi
131+
fm=$(sed -n '2,/^---$/p' "$shared" | head -n -1)
132+
if ! echo "$fm" | grep -q '^version:'; then
133+
fail "$shared: frontmatter missing version field"
134+
else
135+
ver=$(echo "$fm" | grep '^version:' | sed 's/^version: *//')
136+
if ! echo "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
137+
fail "$shared: version '$ver' is not valid semver"
138+
fi
139+
fi
140+
done < <(find _shared -name '*.md' -print0 2>/dev/null)
141+
142+
# ---------------------------------------------------------------------------
143+
# 7. Detect behavioral changes and version bumps per workflow
144+
# ---------------------------------------------------------------------------
145+
declare -A workflow_behavioral_changed
146+
declare -A workflow_version_bumped
147+
declare -A shared_changed
148+
149+
for file in "${CHANGED_FILES[@]}"; do
150+
if [[ "$file" == _shared/* ]]; then
151+
if is_behavioral "$file"; then
152+
shared_changed["$file"]=1
153+
fi
154+
continue
155+
fi
156+
157+
wf="${file%%/*}"
158+
[ -f "$wf/SKILL.md" ] || continue
159+
160+
if is_behavioral "$file"; then
161+
workflow_behavioral_changed["$wf"]=1
162+
fi
163+
164+
if [ "$file" = "$wf/SKILL.md" ]; then
165+
new_ver=$(sed -n 's/^version: *//p' "$wf/SKILL.md")
166+
old_ver=$(git show "$MERGE_BASE:$wf/SKILL.md" 2>/dev/null \
167+
| sed -n 's/^version: *//p' || echo "")
168+
if [ -n "$new_ver" ] && [ -n "$old_ver" ] && [ "$new_ver" != "$old_ver" ]; then
169+
workflow_version_bumped["$wf"]=1
170+
elif [ -n "$new_ver" ] && [ -z "$old_ver" ]; then
171+
workflow_version_bumped["$wf"]=1
172+
fi
173+
fi
174+
done
175+
176+
# ---------------------------------------------------------------------------
177+
# 8. Workflows with behavioral changes must have version bumps
178+
# ---------------------------------------------------------------------------
179+
for wf in "${!workflow_behavioral_changed[@]}"; do
180+
if [ -z "${workflow_version_bumped[$wf]:-}" ]; then
181+
fail "$wf: behavioral files changed but version not bumped in SKILL.md"
182+
else
183+
new_ver=$(sed -n 's/^version: *//p' "$wf/SKILL.md")
184+
old_ver=$(git show "$MERGE_BASE:$wf/SKILL.md" 2>/dev/null \
185+
| sed -n 's/^version: *//p' || echo "")
186+
if [ -n "$old_ver" ] && [ -n "$new_ver" ]; then
187+
if ! semver_lt "$old_ver" "$new_ver"; then
188+
fail "$wf: version $new_ver is not greater than $old_ver"
189+
fi
190+
fi
191+
fi
192+
done
193+
194+
# ---------------------------------------------------------------------------
195+
# 9. Advisory: signals that suggest MAJOR bump
196+
# ---------------------------------------------------------------------------
197+
for file in "${CHANGED_FILES[@]}"; do
198+
wf="${file%%/*}"
199+
[ -f "$wf/SKILL.md" ] || continue
200+
201+
if ! [ -f "$file" ] && git show "$MERGE_BASE:$file" >/dev/null 2>&1; then
202+
case "$file" in
203+
*/skills/*.md|*/commands/*.md)
204+
warn "$wf: deleted $file — consider MAJOR version bump" ;;
205+
esac
206+
fi
207+
done
208+
209+
# ---------------------------------------------------------------------------
210+
# 10. Shared files themselves must have version bumps when changed
211+
# ---------------------------------------------------------------------------
212+
for shared_file in "${!shared_changed[@]}"; do
213+
new_ver=$(sed -n 's/^version: *//p' "$shared_file")
214+
old_ver=$(git show "$MERGE_BASE:$shared_file" 2>/dev/null \
215+
| sed -n 's/^version: *//p' || echo "")
216+
if [ -n "$new_ver" ] && [ -n "$old_ver" ] && [ "$new_ver" = "$old_ver" ]; then
217+
fail "$shared_file: behavioral content changed but version not bumped (still $old_ver)"
218+
elif [ -n "$old_ver" ] && [ -n "$new_ver" ] && [ "$new_ver" != "$old_ver" ]; then
219+
if ! semver_lt "$old_ver" "$new_ver"; then
220+
fail "$shared_file: version $new_ver is not greater than $old_ver"
221+
fi
222+
fi
223+
done
224+
225+
# ---------------------------------------------------------------------------
226+
# 11. Cascade: shared file changes require consuming workflow bumps
227+
# ---------------------------------------------------------------------------
228+
for shared_file in "${!shared_changed[@]}"; do
229+
base=$(basename "$shared_file" .md)
230+
231+
referencing_workflows=""
232+
233+
# Direct references in standard behavioral locations
234+
direct=$(grep -rl "$base" */skills/*.md */commands/*.md */guidelines.md \
235+
2>/dev/null | sed 's|/.*||' | sort -u || true)
236+
if [ -n "$direct" ]; then
237+
referencing_workflows="$direct"
238+
fi
239+
240+
# Also check root-level workflow .md files (e.g., design/decomposition-review.md)
241+
for wf_dir in */; do
242+
wf="${wf_dir%/}"
243+
[ -f "$wf/SKILL.md" ] || continue
244+
for root_md in "$wf"/*.md; do
245+
[ -f "$root_md" ] || continue
246+
case "$(basename "$root_md")" in
247+
SKILL.md|README.md|GUIDE.md|guidelines.md) continue ;;
248+
esac
249+
if grep -q "$base" "$root_md" 2>/dev/null; then
250+
referencing_workflows=$(printf '%s\n%s' "$referencing_workflows" "$wf" | sort -u)
251+
fi
252+
done
253+
done
254+
255+
for wf in $referencing_workflows; do
256+
[ -n "$wf" ] || continue
257+
[ -f "$wf/SKILL.md" ] || continue
258+
if [ -z "${workflow_version_bumped[$wf]:-}" ]; then
259+
fail "$wf: references changed shared file $shared_file but version not bumped"
260+
fi
261+
done
262+
263+
# Transitive: shared file A references shared file B
264+
transitive_shared=$(find _shared -name '*.md' -exec grep -l "$base" {} \; 2>/dev/null || true)
265+
for trans in $transitive_shared; do
266+
[ "$trans" = "$shared_file" ] && continue
267+
trans_base=$(basename "$trans" .md)
268+
trans_workflows=$(grep -rl "$trans_base" */skills/*.md */commands/*.md \
269+
*/guidelines.md 2>/dev/null | sed 's|/.*||' | sort -u || true)
270+
for tw in $trans_workflows; do
271+
[ -f "$tw/SKILL.md" ] || continue
272+
if [ -z "${workflow_version_bumped[$tw]:-}" ]; then
273+
fail "$tw: transitively affected by $shared_file (via $trans) but version not bumped"
274+
fi
275+
done
276+
done
277+
done
278+
279+
# ---------------------------------------------------------------------------
280+
# Summary
281+
# ---------------------------------------------------------------------------
282+
echo
283+
echo "==========================="
284+
if [ "$errors" -gt 0 ]; then
285+
echo "FAILED: $errors error(s), $warnings warning(s)"
286+
exit 1
287+
else
288+
echo "PASSED: $warnings warning(s), no errors"
289+
exit 0
290+
fi

.github/workflows/lint.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ jobs:
4040
persist-credentials: false
4141
- uses: DavidAnson/markdownlint-cli2-action@05f32210e84442804257b2a6f20b273450ec8265 # v19
4242

43+
validate-versions:
44+
name: Validate Versions
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
with:
49+
fetch-depth: 0
50+
persist-credentials: false
51+
- run: bash .github/scripts/validate-versions.sh
52+
4353
link-check:
4454
name: Link Check
4555
runs-on: ubuntu-latest
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Tag Versions
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
concurrency:
8+
group: tag-versions
9+
cancel-in-progress: false
10+
11+
permissions: {}
12+
13+
jobs:
14+
tag:
15+
name: Auto-Tag Version Bumps
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: write # required to create and push git tags
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
fetch-depth: 2
23+
persist-credentials: true # required for git push of tags
24+
- name: Detect and tag version bumps
25+
run: |
26+
# Merge commits have >2 words in parent list; use first parent (main before merge)
27+
if [ "$(git rev-list --parents -1 HEAD | wc -w)" -gt 2 ]; then
28+
PARENT=$(git rev-parse HEAD^1)
29+
else
30+
PARENT=$(git rev-parse HEAD~1)
31+
fi
32+
33+
NEW_TAGS=()
34+
35+
# Tag workflow version bumps
36+
for skill in */SKILL.md; do
37+
[ -f "$skill" ] || continue
38+
wf=$(dirname "$skill")
39+
new_ver=$(sed -n 's/^version: *//p' "$skill")
40+
old_ver=$(git show "$PARENT:$skill" 2>/dev/null \
41+
| sed -n 's/^version: *//p' || echo "")
42+
if [ -n "$new_ver" ] && [ "$new_ver" != "$old_ver" ]; then
43+
tag="${wf}/v${new_ver}"
44+
if git rev-parse "$tag" >/dev/null 2>&1; then
45+
echo "Tag $tag already exists, skipping"
46+
else
47+
echo "Tagging $tag"
48+
git tag "$tag"
49+
NEW_TAGS+=("$tag")
50+
fi
51+
fi
52+
done
53+
54+
# Tag shared file version bumps
55+
while IFS= read -r -d '' shared; do
56+
[ -f "$shared" ] || continue
57+
new_ver=$(sed -n 's/^version: *//p' "$shared")
58+
[ -n "$new_ver" ] || continue
59+
old_ver=$(git show "$PARENT:$shared" 2>/dev/null \
60+
| sed -n 's/^version: *//p' || echo "")
61+
if [ "$new_ver" != "$old_ver" ]; then
62+
base=$(basename "$shared" .md)
63+
tag="_shared/${base}/v${new_ver}"
64+
if git rev-parse "$tag" >/dev/null 2>&1; then
65+
echo "Tag $tag already exists, skipping"
66+
else
67+
echo "Tagging $tag"
68+
git tag "$tag"
69+
NEW_TAGS+=("$tag")
70+
fi
71+
fi
72+
done < <(find _shared -name '*.md' -print0 2>/dev/null)
73+
74+
if [ ${#NEW_TAGS[@]} -gt 0 ]; then
75+
echo "Pushing ${#NEW_TAGS[@]} new tag(s)"
76+
git push origin "${NEW_TAGS[@]}"
77+
else
78+
echo "No new tags to push"
79+
fi

0 commit comments

Comments
 (0)