diff --git a/.claude/agents/action-validator.md b/.claude/agents/action-validator.md new file mode 100644 index 0000000..c9e8105 --- /dev/null +++ b/.claude/agents/action-validator.md @@ -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 +``` + +Output a summary table of violations found, grouped by action. diff --git a/.claude/agents/test-coverage-reviewer.md b/.claude/agents/test-coverage-reviewer.md new file mode 100644 index 0000000..c8ffc22 --- /dev/null +++ b/.claude/agents/test-coverage-reviewer.md @@ -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//` +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) diff --git a/.claude/hooks/block-rules-yml.sh b/.claude/hooks/block-rules-yml.sh new file mode 100755 index 0000000..d289614 --- /dev/null +++ b/.claude/hooks/block-rules-yml.sh @@ -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 diff --git a/.claude/hooks/post-edit-write.sh b/.claude/hooks/post-edit-write.sh new file mode 100755 index 0000000..0efb7c7 --- /dev/null +++ b/.claude/hooks/post-edit-write.sh @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9f967c6 --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} diff --git a/.claude/skills/check-pins/SKILL.md b/.claude/skills/check-pins/SKILL.md new file mode 100644 index 0000000..8edad8a --- /dev/null +++ b/.claude/skills/check-pins/SKILL.md @@ -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@` +- 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. diff --git a/.claude/skills/new-action/SKILL.md b/.claude/skills/new-action/SKILL.md new file mode 100644 index 0000000..2404254 --- /dev/null +++ b/.claude/skills/new-action/SKILL.md @@ -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.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@` +- Add `id:` to steps whose outputs are referenced + +## 3. Generate validation rules + +```bash +make update-validators +``` + +This generates `/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. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..dcd7039 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -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. diff --git a/.claude/skills/test-action/SKILL.md b/.claude/skills/test-action/SKILL.md new file mode 100644 index 0000000..5c44b92 --- /dev/null +++ b/.claude/skills/test-action/SKILL.md @@ -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= +``` + +## 3. Display results + +Show the test output. If tests fail, read the relevant test files in `_tests/unit//` and the action's `action.yml` to help diagnose the issue. + +## 4. Coverage (optional) + +If the user wants coverage information: + +```bash +make test-coverage +``` diff --git a/.claude/skills/validate/SKILL.md b/.claude/skills/validate/SKILL.md new file mode 100644 index 0000000..86a47e9 --- /dev/null +++ b/.claude/skills/validate/SKILL.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index c91349f..751a7ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/Makefile b/Makefile index 7138446..28bb121 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/_tools/check-version-refs.sh b/_tools/check-version-refs.sh index 7e1f283..8cfb35b 100755 --- a/_tools/check-version-refs.sh +++ b/_tools/check-version-refs.sh @@ -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