chore(claude): add hooks, skills, and agents for Claude Code (#496)

* chore(claude): add hooks, skills, and agents for Claude Code

Add auto-formatting hooks (ruff, shfmt, prettier, actionlint),
rules.yml edit blocker, 5 skills (/release, /test-action,
/new-action, /validate, /check-pins), and 2 subagents
(action-validator, test-coverage-reviewer). Update CLAUDE.md
with hook documentation.

* fix(claude): add tool availability guards and fix skill docs

Add jq availability checks to hook scripts (block-rules-yml.sh,
post-edit-write.sh) and wrap actionlint call in command -v guard,
consistent with project rules #2 and #10. Fix validate skill to
reflect actual make all pipeline order and note that make test
runs separately.

* fix(claude): correct skill docs per PR review feedback

Fix validate skill description to say "precommit" instead of "test",
and fix check-pins SHA guidance to use origin/main instead of HEAD.

* feat(tools): add SHA-pinning enforcement to check-version-refs

The check-version-refs script previously only displayed existing
SHA-pinned refs but silently skipped non-SHA references. Add a
validation pass that detects and reports any ivuorinen/actions/*
references not using a 40-char hex SHA, exiting 1 on violations.

* fix(tools): fix temp file leak in check-version-refs.sh

Write find output directly to $violations_file instead of
$violations_file.all so the EXIT trap covers cleanup on all
exit paths, not just the happy path.
This commit is contained in:
2026-03-08 04:22:02 +02:00
committed by GitHub
parent 242ecca8f0
commit f995f89a21
13 changed files with 440 additions and 8 deletions

View File

@@ -0,0 +1,30 @@
You review action.yml files against the repository's critical prevention rules.
Check each action.yml file for these violations:
1. All external action refs are SHA-pinned (not @main/@v1)
2. All internal action refs use `ivuorinen/actions/name@SHA` format
3. Shell scripts use `set -eu` (POSIX, not bash)
4. Steps with referenced outputs have `id:` fields
5. Tool availability checked before use (`command -v`)
6. Variables properly quoted (`"$var"`)
7. `$GITHUB_OUTPUT` uses `printf`, not `echo`
8. No nested `${{ }}` in quoted YAML strings
9. Token inputs use `${{ github.token }}` default
10. Fallbacks provided for tools not on all runners
Run `actionlint` on each file. Report violations with file path, line, and fix suggestion.
To find all action.yml files:
```bash
find . -name "action.yml" -not -path "./.git/*"
```
For each file, read it and check against all 10 rules. Then run:
```bash
actionlint <file>
```
Output a summary table of violations found, grouped by action.

View File

@@ -0,0 +1,33 @@
You review test coverage for GitHub Actions in this monorepo.
For each action:
1. Read the action.yml to understand inputs, outputs, and steps
2. Read the corresponding test files in `_tests/unit/<action-name>/`
3. Check if all inputs have validation tests
4. Check if error paths are tested (missing required inputs, invalid values)
5. Check if shell scripts have edge case tests (spaces in paths, empty strings, special chars)
6. Report coverage gaps with specific test suggestions
To find all actions and their tests:
```bash
ls -d */action.yml | sed 's|/action.yml||'
ls -d _tests/unit/*/
```
Compare the two lists to find actions without any tests.
For each action with tests, check coverage of:
- All required inputs validated
- All optional inputs with defaults tested
- Error conditions (missing inputs, invalid formats)
- Edge cases in shell logic (empty strings, special characters, spaces in paths)
- Output values verified
Output a coverage report with:
- Actions with no tests (critical)
- Actions with partial coverage (list missing test cases)
- Actions with good coverage (brief confirmation)

View File

@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
# Read JSON input from stdin to get the file path
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not found" >&2
exit 1
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
case "$FILE_PATH" in
*/rules.yml)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rules.yml files are auto-generated. Run make update-validators instead."}}'
;;
esac

View File

@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
# Read JSON input from stdin to get the file path
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not found" >&2
exit 1
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
case "$FILE_PATH" in
*/rules.yml)
# rules.yml should not be reached here (blocked by PreToolUse),
# but skip formatting just in case
exit 0
;;
*.py)
ruff format --quiet "$FILE_PATH" 2>/dev/null || true
ruff check --fix --quiet "$FILE_PATH" 2>/dev/null || true
;;
*.sh)
shfmt -w "$FILE_PATH" 2>/dev/null || true
shellcheck "$FILE_PATH" 2>&1 || true
;;
*.yml | *.yaml | *.json)
npx prettier --write "$FILE_PATH" 2>/dev/null || true
;;
*.md)
npx prettier --write "$FILE_PATH" 2>/dev/null || true
;;
esac
# Run actionlint on action.yml files
case "$FILE_PATH" in
*/action.yml)
if command -v actionlint >/dev/null 2>&1; then
actionlint "$FILE_PATH" 2>&1 || true
fi
;;
esac

26
.claude/settings.json Normal file
View File

@@ -0,0 +1,26 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rules-yml.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-write.sh"
}
]
}
]
}
}

View File

@@ -0,0 +1,40 @@
---
name: check-pins
description: Verify all action references are properly SHA-pinned
disable-model-invocation: true
---
# Check SHA-Pinned Action References
## 1. Check version references
```bash
make check-version-refs
```
This verifies that all `ivuorinen/actions/*` references in `action.yml` files use SHA-pinned commits.
## 2. Check local references
```bash
make check-local-refs
```
This verifies that test workflows use `./action-name` format (local references are allowed in tests).
## 3. Interpret results
**Violations to fix:**
- `@main` or `@v*` references in `action.yml` files must be replaced with full SHA commits
- `./action-name` in `action.yml` (non-test) files must use `ivuorinen/actions/action-name@<SHA>`
- External actions must be pinned to SHA commits, not version tags
**How to get the SHA for pinning:**
```bash
# After pushing, get the SHA of the latest commit on the remote
git rev-parse origin/main
```
Use a SHA that exists on the remote. Local-only commits won't resolve when the action is used externally.

View File

@@ -0,0 +1,60 @@
---
name: new-action
description: Scaffold a new GitHub Action with all required files
disable-model-invocation: true
---
# Scaffold a New GitHub Action
## 1. Gather information
Ask the user for:
- **Action name** (kebab-case, e.g. `my-new-action`)
- **Description** (one line)
- **Category** (setup, linting, testing, build, publishing, repository, utility)
- **Inputs** (name, description, required, default for each)
- **What it does** (shell commands, composite steps, etc.)
## 2. Create directory and action.yml
Create `<action-name>/action.yml` following the existing action patterns:
- Use `composite` runs type
- Include `set -eu` in shell scripts (POSIX sh, not bash)
- Use `${{ github.token }}` for token defaults
- Pin all external action references to SHA commits
- Pin internal action references using `ivuorinen/actions/action-name@<SHA>`
- Add `id:` to steps whose outputs are referenced
## 3. Generate validation rules
```bash
make update-validators
```
This generates `<action-name>/rules.yml` from the action's inputs.
## 4. Generate test scaffolding
```bash
make generate-tests
```
## 5. Generate README
```bash
make docs
```
## 6. Run validation
```bash
make all
```
Fix any issues before considering the action complete.
## 7. Update repository overview
Remind the user to update the Serena memory `repository_overview` if they use Serena.

View File

@@ -0,0 +1,57 @@
---
name: release
description: Create a new CalVer release with validation checks
disable-model-invocation: true
---
# Release Workflow
Follow these steps to create a new CalVer release:
## 1. Pre-flight checks
Run the full validation pipeline:
```bash
make all
```
If any step fails, fix the issues before proceeding.
## 2. Check version references
Verify all action references are properly pinned:
```bash
make check-version-refs
make check-local-refs
```
## 3. Prepare the release
Run release preparation (updates version references):
```bash
make release-prep
```
Review the changes with `git diff`.
## 4. Confirm with user
Ask the user to confirm:
- The version number (defaults to `vYYYY.MM.DD` based on today's date)
- That all changes look correct
## 5. Create the release
```bash
make release VERSION=vYYYY.MM.DD
```
Replace `vYYYY.MM.DD` with the confirmed version.
## 6. Verify
Show the user the created tag and any output from the release process.

View File

@@ -0,0 +1,34 @@
---
name: test-action
description: Run tests for a specific GitHub Action by name
disable-model-invocation: true
---
# Test a Specific Action
## 1. Identify the action
Ask the user which action to test if not already specified.
List available actions if needed:
```bash
ls -d */action.yml | sed 's|/action.yml||'
```
## 2. Run tests
```bash
make test-action ACTION=<action-name>
```
## 3. Display results
Show the test output. If tests fail, read the relevant test files in `_tests/unit/<action-name>/` and the action's `action.yml` to help diagnose the issue.
## 4. Coverage (optional)
If the user wants coverage information:
```bash
make test-coverage
```

View File

@@ -0,0 +1,51 @@
---
name: validate
description: Run full validation pipeline (docs, format, lint, precommit)
disable-model-invocation: true
---
# Full Validation Pipeline
Run the complete validation pipeline:
```bash
make all
```
This runs in order: `install-tools` -> `update-validators` -> `docs` -> `update-catalog` -> `format` -> `lint` -> `precommit`
**Note:** `make test` must be run separately.
## If validation fails
### Formatting issues
```bash
make format
```
Then re-run `make all`.
### Linting issues
- **actionlint**: Check action.yml syntax, step IDs, expression usage
- **shellcheck**: POSIX compliance, quoting, variable usage
- **ruff**: Python style and errors
- **markdownlint**: Markdown formatting
- **prettier**: YAML/JSON/MD formatting
### Test failures
```bash
make test
```
Read the failing test output and fix the underlying action or test.
### Documentation drift
```bash
make docs
```
Regenerates READMEs from action.yml files.

View File

@@ -25,12 +25,22 @@
### Folders
- `.serena/` Internal config (do not edit)
- `.claude/hooks/` Claude Code hook scripts (auto-format, lint, block rules.yml edits)
- `.claude/skills/` Claude Code skills (`/release`, `/test-action`, `/new-action`, `/validate`, `/check-pins`)
- `.claude/agents/` Claude Code subagents (action-validator, test-coverage-reviewer)
- `.github/` Workflows/templates
- `_tests/` ShellSpec tests
- `_tools/` Helper tools
- `validate-inputs/` Python validation system + tests
- `*/rules.yml` Auto-generated validation rules
### Claude Code Hooks
**Auto-formatting**: PostToolUse hooks auto-format files on Edit/Write (ruff for .py, shfmt for .sh, prettier for .yml/.yaml/.json/.md, actionlint for action.yml)
**Blocked edits**: PreToolUse hook blocks direct edits to `rules.yml` (auto-generated, use `make update-validators`)
**Hook schema**: `matcher` is a regex string matching tool names (e.g. `"Edit|Write"`), not an object. File filtering done in hook scripts via stdin JSON (`jq -r '.tool_input.file_path'`)
**Reference**: `$CLAUDE_PROJECT_DIR` for project-relative paths in hook commands
### Memory System
**Location**: `.serena/memories/` (9 consolidated memories for context)

View File

@@ -210,7 +210,7 @@ bump-major-version: ## Replace one major version with another (usage: make bump-
@sh _tools/bump-major-version.sh "$(OLD)" "$(NEW)"
@echo "$(GREEN)✅ Major version bumped$(RESET)"
check-version-refs: ## List all current SHA-pinned action references
check-version-refs: ## Verify all action references are SHA-pinned
@echo "$(BLUE)🔍 Checking action references...$(RESET)"
@sh _tools/check-version-refs.sh

View File

@@ -23,19 +23,43 @@ for tool in find grep sed printf sort cut tr wc; do
fi
done
# --- Validation pass: detect non-SHA-pinned references ---
violations_file=$(safe_mktemp)
trap 'rm -f "$violations_file"' EXIT
find . -maxdepth 2 -name "action.yml" -path "*/action.yml" \
! -path "./_*" ! -path "./.github/*" \
-exec grep -nE '^\s+uses:\s+ivuorinen/actions/' {} /dev/null \; \
>"$violations_file"
violations_found=false
while IFS= read -r match; do
if ! printf '%s\n' "$match" | grep -qE '@[0-9a-f]{40}'; then
if [ "$violations_found" = false ]; then
msg_error "Non-SHA-pinned action references found:"
violations_found=true
fi
printf ' %s\n' "$match" >&2
fi
done <"$violations_file"
if [ "$violations_found" = true ]; then
rm -f "$violations_file"
exit 1
fi
rm -f "$violations_file"
printf '%b' "${BLUE}Current SHA-pinned action references:${NC}\n"
printf '\n'
# Create temp files for processing
temp_file=$(safe_mktemp)
trap 'rm -f "$temp_file"' EXIT
temp_input=$(safe_mktemp)
trap 'rm -f "$temp_file" "$temp_input"' EXIT
# Find all action references and collect SHA|action pairs
# Use input redirection to avoid subshell issues with pipeline
find . -maxdepth 2 -name "action.yml" -path "*/action.yml" ! -path "./_*" ! -path "./.github/*" -exec grep -h "uses: ivuorinen/actions/" {} \; > "$temp_input"
find . -maxdepth 2 -name "action.yml" -path "*/action.yml" ! -path "./_*" ! -path "./.github/*" -exec grep -h "uses: ivuorinen/actions/" {} \; >"$temp_input"
while IFS= read -r line; do
# Extract action name and SHA using sed
@@ -43,9 +67,9 @@ while IFS= read -r line; do
sha=$(echo "$line" | sed -n 's|.*@\([a-f0-9]\{40\}\).*|\1|p')
if [ -n "$action" ] && [ -n "$sha" ]; then
printf '%s\n' "$sha|$action" >> "$temp_file"
printf '%s\n' "$sha|$action" >>"$temp_file"
fi
done < "$temp_input"
done <"$temp_input"
# Check if we found any references
if [ ! -s "$temp_file" ]; then
@@ -54,7 +78,7 @@ if [ ! -s "$temp_file" ]; then
fi
# Sort by SHA and group
sort "$temp_file" | uniq > "${temp_file}.sorted"
sort "$temp_file" | uniq >"${temp_file}.sorted"
mv "${temp_file}.sorted" "$temp_file"
# Count unique SHAs
@@ -95,7 +119,7 @@ while IFS='|' read -r sha action; do
# Add to current SHA group
actions_list="$actions_list, $action"
fi
done < "$temp_file"
done <"$temp_file"
# Print last SHA group
if [ -n "$current_sha" ]; then