From 412b50e5fbdba50375980817b1a781c74d175ff9 Mon Sep 17 00:00:00 2001 From: david22swan Date: Wed, 24 Jun 2026 09:53:10 +0100 Subject: [PATCH 1/2] Add Claude Code guidance and enforcement hooks Rewrite CLAUDE.md to document the repo as a reusable-workflow library (architecture, conventions, fork-PR security model, linting) and add PreToolUse hooks under .claude/ enforcing project safety rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/_parse_input.sh | 14 +++ .claude/hooks/no-main-commits.sh | 13 +++ .claude/hooks/no-pr-merge.sh | 10 ++ .claude/hooks/no-push.sh | 10 ++ .claude/hooks/no-rm.sh | 11 ++ .claude/settings.json | 32 ++++++ CLAUDE.md | 168 +++++++++++++++++++++++-------- 7 files changed, 214 insertions(+), 44 deletions(-) create mode 100755 .claude/hooks/_parse_input.sh create mode 100755 .claude/hooks/no-main-commits.sh create mode 100755 .claude/hooks/no-pr-merge.sh create mode 100755 .claude/hooks/no-push.sh create mode 100755 .claude/hooks/no-rm.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/_parse_input.sh b/.claude/hooks/_parse_input.sh new file mode 100755 index 00000000..832772de --- /dev/null +++ b/.claude/hooks/_parse_input.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Shared helper: reads hook JSON from stdin and sets $cmd to the Bash tool command. +# Sources into each hook with: . "$(dirname "$0")/_parse_input.sh" +# Exits 0 (allow) if no JSON parser is available. + +if command -v jq >/dev/null 2>&1; then + # shellcheck disable=SC2034 # cmd is used by the sourcing hook script + cmd=$(jq -r '.tool_input.command // ""' 2>/dev/null || echo "") +elif command -v python3 >/dev/null 2>&1; then + # shellcheck disable=SC2034 # cmd is used by the sourcing hook script + cmd=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +else + exit 0 +fi diff --git a/.claude/hooks/no-main-commits.sh b/.claude/hooks/no-main-commits.sh new file mode 100755 index 00000000..33e09bd5 --- /dev/null +++ b/.claude/hooks/no-main-commits.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Project rule: never work directly on the main or master branch. +# Blocks: git commit when current branch is main or master. + +# shellcheck source=/dev/null +. "$(dirname "$0")/_parse_input.sh" + +if echo "$cmd" | grep -qE '(^|[;&|`(])[[:space:]]*(([[:alpha:]_][[:alnum:]_]*=[^[:space:]]*[[:space:]]+)*)([^[:space:]]*/)?git[[:space:]]+commit([[:space:]]|$|[;&|])'; then + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) + if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then + echo "{\"continue\":false,\"stopReason\":\"Project rule: never work directly on the $branch branch.\"}" + fi +fi diff --git a/.claude/hooks/no-pr-merge.sh b/.claude/hooks/no-pr-merge.sh new file mode 100755 index 00000000..3f78a7bb --- /dev/null +++ b/.claude/hooks/no-pr-merge.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Project rule: never merge a pull request. +# Blocks: gh pr merge + +# shellcheck source=/dev/null +. "$(dirname "$0")/_parse_input.sh" + +if echo "$cmd" | grep -qE '(^|[;&|])[[:space:]]*gh pr merge'; then + echo '{"continue":false,"stopReason":"Project rule: never merge a pull request."}' +fi diff --git a/.claude/hooks/no-push.sh b/.claude/hooks/no-push.sh new file mode 100755 index 00000000..e7aab0d5 --- /dev/null +++ b/.claude/hooks/no-push.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Project rule: never push a branch without explicit instruction. +# Blocks: git push + +# shellcheck source=/dev/null +. "$(dirname "$0")/_parse_input.sh" + +if echo "$cmd" | grep -qE '(^|[;&|`(])[[:space:]]*(([[:alpha:]_][[:alnum:]_]*=[^[:space:]]*[[:space:]]+)*)([^[:space:]]*/)?git[[:space:]]+push([[:space:]]|$|[;&|])'; then + echo '{"continue":false,"stopReason":"Project rule: never push a branch without explicit instruction."}' +fi diff --git a/.claude/hooks/no-rm.sh b/.claude/hooks/no-rm.sh new file mode 100755 index 00000000..02d1543f --- /dev/null +++ b/.claude/hooks/no-rm.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Project rule: never delete a file without explicit permission. +# Blocks: rm, sudo rm, xargs rm, /bin/rm, /usr/bin/rm (and similar absolute paths). +# Does not cover: find -exec rm (rm as a subprocess argument, not a shell token). + +# shellcheck source=/dev/null +. "$(dirname "$0")/_parse_input.sh" + +if echo "$cmd" | grep -qE '(^|[;&|])[[:space:]]*(sudo[[:space:]]+|xargs[[:space:]]+)?([^[:space:]]*/)?rm([[:space:]]|$)'; then + echo '{"continue":false,"stopReason":"Project rule: never delete a file without explicit permission."}' +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d2aed15a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/no-pr-merge.sh", + "timeout": 5, + "statusMessage": "Checking project rules..." + }, + { + "type": "command", + "command": ".claude/hooks/no-main-commits.sh", + "timeout": 5 + }, + { + "type": "command", + "command": ".claude/hooks/no-push.sh", + "timeout": 5 + }, + { + "type": "command", + "command": ".claude/hooks/no-rm.sh", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 08c40d32..4cfe6f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,51 +1,131 @@ -# Claude in This Repository - -## How Claude Is Used - -Claude runs automatically on every pull request via GitHub Actions. It does **not** need to be invoked manually — it triggers on its own whenever a PR is opened, updated, marked ready for review, or reopened. - -Draft PRs are skipped. Claude will begin reviewing once a PR is marked ready for review. - -## What Triggers a Review - -The workflow fires on these PR events: - -- `opened` — a new PR is created -- `synchronize` — new commits are pushed to an existing PR -- `ready_for_review` — a draft PR is promoted to ready -- `reopened` — a previously closed PR is reopened - -## What Claude Does - -Claude reviews the pull request and posts its feedback as a PR comment. Only one review runs at a time per PR — if a new commit is pushed while a review is in progress, the in-flight review is cancelled and a fresh one starts. - -## Workflow Location - -The GitHub Actions workflow is defined at: - -``` -.github/workflows/claude-pr.yml +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Hard Constraints + +- At the start of a session, review the repository structure and any relevant + README or documentation files to understand the area you are working in. +- Always read the files relevant to the task before suggesting or making a change. +- Never merge a pull request. +- Never work directly on the `main` or `master` branch. +- Never push a branch without explicit instruction. These workflows are consumed + by many downstream repositories — an accidental push affects every consumer. +- Never delete a file without permission — this applies even after a blanket + "yes to all". +- Never output, log, save, or hardcode security-sensitive values — passwords, + tokens, API keys, private keys, secrets, or credentials of any kind. + +These are enforced by `PreToolUse` hooks in `.claude/settings.json` +(`no-pr-merge.sh`, `no-main-commits.sh`, `no-push.sh`, `no-rm.sh`). + +## What This Repo Is + +This repository is a **library of reusable GitHub Actions workflows** for the +Content and Tooling (CAT) team at Puppet/Perforce. It contains almost no +application code — the deliverables are the workflow YAML files themselves. +Downstream Puppet **modules** and Ruby **gems/tools** consume these workflows to +get standardized CI, acceptance testing, vulnerability scanning, and release +automation without duplicating the logic in every repo. + +There is nothing to build. The unit of work is editing a workflow definition and +reasoning about how it behaves when called from another repository. + +## Architecture + +Almost every workflow under [.github/workflows/](.github/workflows/) is a +**reusable workflow** — it begins with `on: workflow_call:` and declares typed +`inputs`. Consumers reference them by full path and ref, e.g.: + +```yaml +jobs: + Spec: + uses: "puppetlabs/cat-github-actions/.github/workflows/module_ci.yml@main" + secrets: "inherit" ``` -## Required Secrets - -The workflow requires the following secrets to be configured in the repository (or organization) settings: - -| Secret | Purpose | -| --------------------------- | ---------------------------------------------------------------------- | -| `ANTHROPIC_CODE_REVIEW_KEY` | Anthropic API key used to authenticate Claude | -| `GITHUB_TOKEN` | Automatically provided by GitHub Actions; used to post review comments | +The workflows split along two product lines and a lifecycle: + +| Domain | CI / Test | Security | Release | +| ----------- | ----------------------------------------- | --------------- | ----------------------------------------------- | +| **Modules** | `module_ci.yml`, `module_acceptance.yml` | `mend_ruby.yml` | `module_release_prep.yml`, `module_release.yml` | +| **Gems** | `gem_ci.yml`, `gem_acceptance.yml` | `tooling_mend_ruby.yml` | `gem_release_prep.yml`, `gem_release.yml` | + +Cross-cutting workflows: + +- [lint.yml](.github/workflows/lint.yml) — the **only** workflow that runs on + this repo's own pushes/PRs (not `workflow_call`); yamllints `.github/`. +- [claude-pr.yml](.github/workflows/claude-pr.yml) — auto PR review (see below). +- [fork_ci_label_guard.yml](.github/workflows/fork_ci_label_guard.yml) — part of + the fork-PR security model (see below). +- [workflow-restarter.yml](.github/workflows/workflow-restarter.yml) + + [.github/actions/workflow-restarter-proxy/](.github/actions/workflow-restarter-proxy/) — + a workflow cannot re-trigger itself, so a caller uses the composite *proxy + action* to dispatch the *restarter workflow*, which retries a failed run up to + N times. See [docs/workflow-restarter.md](docs/workflow-restarter.md). + +[example/module/](example/module/) holds copy-paste **caller** workflows showing +how a consuming module wires these together. These are the canonical reference +for the intended consumption pattern — read them before changing an interface. + +## Conventions When Editing Workflows + +- **Inputs are a public API.** Renaming or removing an input, or changing a + default, breaks every downstream caller. Add new inputs with sensible defaults + rather than repurposing existing ones, and update [example/module/](example/module/) + and [README.md](README.md) to match. +- **Pin third-party actions to a commit SHA** with a trailing version comment + (e.g. `reviewdog/action-shellcheck@4c07...86e # v1`). First-party + `actions/*` and `ruby/setup-ruby` are pinned to a tag. +- **Secrets reach reusable workflows only when the caller sets `secrets: inherit`.** + Release and acceptance workflows document this requirement in a header comment. +- The matrix for module CI/acceptance is generated by + `bundle exec matrix_from_metadata_v3` (from the consuming module's metadata). +- Forge auth uses `secrets.PUPPET_FORGE_TOKEN || secrets.PUPPET_FORGE_TOKEN_PUBLIC`, + written into `$GITHUB_ENV` as `PUPPET_FORGE_TOKEN`. + +## Fork PR Security Model + +Acceptance tests need secrets, but running them automatically on fork PRs would +leak those secrets. The pattern (see [docs/how-to/fork-pr-ci.md](docs/how-to/fork-pr-ci.md) +and [example/module/ci.yml](example/module/ci.yml)): + +- **Public track:** unit/spec tests run via `pull_request` with **no** secrets. +- **Private track:** acceptance runs via `pull_request_target` (which exposes + secrets), gated on an `allowed-for-ci` label that only a CODEOWNER can apply. +- `fork_ci_label_guard.yml` **strips** that label on `synchronize`/`closed`, so + every new commit re-requires a fresh CODEOWNER review and re-label. The caller + intentionally omits `synchronize` from its acceptance trigger to avoid racing + the guard. + +When touching anything in this area, preserve the property that **untrusted code +from a fork can never run with secrets without a CODEOWNER re-approving the +exact commits**. + +## Linting + +CI lints YAML only. To reproduce locally: + +```bash +yamllint -c .yamllint .github/ +``` -## Permissions +Config: [.yamllint](.yamllint) extends `default` and disables `document-start`, +`truthy`, and `line-length`. -Claude's workflow runs with the following GitHub permissions: +## Claude PR Review -- `contents: read` — to check out and read the code -- `pull-requests: write` — to post review comments on the PR +`claude-pr.yml` runs Claude automatically on every PR (opened, synchronize, +ready_for_review, reopened); draft PRs are skipped. It posts advisory review +comments and requires the `ANTHROPIC_CODE_REVIEW_KEY` secret (plus the +auto-provided `GITHUB_TOKEN`). Only one review runs per PR at a time — +in-flight reviews are cancelled when new commits arrive. Merge decisions remain +with human reviewers. -## Notes for Contributors +## Note on the Two `claude` Directories -- You do not need to do anything to trigger a review — it runs automatically. -- If you push additional commits, the previous review job will be cancelled and a new one will start. -- Claude's comments will appear in the PR alongside human reviewer comments. -- Claude's feedback is advisory. Merging decisions remain with human reviewers. +- [.claude/](.claude/) — local Claude Code config for *this* repo (the + enforcement hooks above). Not consumed by downstream repos. +- [claude/github-integration/](claude/github-integration/) — a Claude Code + *plugin/skill* (`init-repo`) that bootstraps Claude PR-review integration into + *other* repositories. Unrelated to `.claude/`. From dd922daf18d4bb6ffe67ce34f226824259a0795b Mon Sep 17 00:00:00 2001 From: david22swan Date: Wed, 24 Jun 2026 10:09:35 +0100 Subject: [PATCH 2/2] Add module_pdk_update reusable workflow Run `pdk update` against the calling module and open (or refresh) a `pdk_update` PR with the template changes. A fixed branch name means a later run overwrites the existing open PR. The PR title and commit message are "(maint) PDK Update" and the body reports the template URL/ref and pdk version. Includes an example caller and a README entry, and updates the CLAUDE.md action-pinning guidance to prefer major-version tags. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/module_pdk_update.yml | 99 +++++++++++++++++++++++++ CLAUDE.md | 8 +- README.md | 1 + example/module/pdk_update.yml | 17 +++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/module_pdk_update.yml create mode 100644 example/module/pdk_update.yml diff --git a/.github/workflows/module_pdk_update.yml b/.github/workflows/module_pdk_update.yml new file mode 100644 index 00000000..b42f49fc --- /dev/null +++ b/.github/workflows/module_pdk_update.yml @@ -0,0 +1,99 @@ +# This is a generic workflow for running `pdk update` against a Puppet module +# and opening (or refreshing) a PR with the resulting template changes. +# +# Behaviour: +# - Runs `pdk update --force` on the calling repository. +# - If the update produces no changes, no branch or PR is created. +# - If there are changes, they are committed to the `pdk_update` branch and a +# PR is opened against the default branch. +# - Because the branch name is fixed, a subsequent run force-updates the same +# branch and overwrites the existing open PR instead of opening a new one. +# +# The default `GITHUB_TOKEN` is used to push the branch and open the PR. Note +# that PRs created with `GITHUB_TOKEN` do not themselves trigger further +# workflow runs (e.g. CI). To have CI run on the PR, pass a PAT or app token as +# the `token` secret. +name: "Module PDK Update" + +on: + workflow_call: + inputs: + ruby_version: + description: "The target Ruby version." + required: false + default: "3.1" + type: "string" + runs_on: + description: "The operating system used for the runner." + required: false + default: "ubuntu-latest" + type: "string" + pdk_version: + description: "Version of the pdk gem to install. Empty installs the latest." + required: false + default: '' + type: "string" + secrets: + token: + description: "Token used to push the branch and open the PR. Defaults to GITHUB_TOKEN." + required: false + +permissions: + contents: write + pull-requests: write + +jobs: + pdk_update: + name: "PDK Update" + runs-on: ${{ inputs.runs_on }} + + steps: + + - name: "Checkout" + uses: "actions/checkout@v6" + with: + fetch-depth: 0 + + - name: "Setup ruby" + uses: "ruby/setup-ruby@v1" + with: + ruby-version: ${{ inputs.ruby_version }} + + - name: "Install pdk" + run: | + if [ -n "${{ inputs.pdk_version }}" ]; then + gem install pdk --version "${{ inputs.pdk_version }}" --no-document + else + gem install pdk --no-document + fi + + - name: "Run pdk update" + run: | + pdk update --force + + - name: "Gather template info" + id: "template" + run: | + # pdk records the template it synced against in metadata.json. + echo "url=$(jq --raw-output '."template-url" // "unknown"' metadata.json)" >> $GITHUB_OUTPUT + echo "ref=$(jq --raw-output '."template-ref" // "unknown"' metadata.json)" >> $GITHUB_OUTPUT + echo "pdk_version=$(pdk --version)" >> $GITHUB_OUTPUT + + - name: "Create pull request" + uses: "peter-evans/create-pull-request@v7" + with: + token: ${{ secrets.token || secrets.GITHUB_TOKEN }} + commit-message: "(maint) PDK Update" + branch: "pdk_update" + delete-branch: true + base: ${{ github.event.repository.default_branch }} + title: "(maint) PDK Update" + body: | + Automated `pdk update` from commit ${{ github.sha }}. + + This PR applies the latest [pdk-templates](https://github.com/puppetlabs/pdk-templates) changes to the module. Please review the diff before merging. + + - **Template URL:** `${{ steps.template.outputs.url }}` + - **Template ref:** `${{ steps.template.outputs.ref }}` + - **PDK version:** `${{ steps.template.outputs.pdk_version }}` + labels: "maintenance" diff --git a/CLAUDE.md b/CLAUDE.md index 4cfe6f6c..c73bf271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,9 +74,11 @@ for the intended consumption pattern — read them before changing an interface. default, breaks every downstream caller. Add new inputs with sensible defaults rather than repurposing existing ones, and update [example/module/](example/module/) and [README.md](README.md) to match. -- **Pin third-party actions to a commit SHA** with a trailing version comment - (e.g. `reviewdog/action-shellcheck@4c07...86e # v1`). First-party - `actions/*` and `ruby/setup-ruby` are pinned to a tag. +- **Pin actions to a major-version tag** (e.g. `actions/checkout@v6`, + `peter-evans/create-pull-request@v7`). Use a commit SHA (with a trailing + version comment, e.g. `reviewdog/action-shellcheck@4c07...86e # v1`) only + when a specific revision must be brought in — for example to satisfy a + security review, as the fork-CI workflows do. - **Secrets reach reusable workflows only when the caller sets `secrets: inherit`.** Release and acceptance workflows document this requirement in a header comment. - The matrix for module CI/acceptance is generated by diff --git a/README.md b/README.md index c0d2dc45..68adc693 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The following are the workflows we currently maintain in this repository: * mend_ruby: automates the usage of mend for vulnerability scanning on modules * module_acceptance: runs automated acceptance CI for modules on PRs * module_ci: runs automated unit testing CI for modules on PRs +* module_pdk_update: runs `pdk update` on a module and opens/refreshes a `pdk_update` PR with the template changes * fork_ci_label_guard: strips the `allowed-for-ci` label from fork PRs on each new commit so privileged acceptance tests re-require CODEOWNER review * module_release_prep: prepares the module for release by running necessary pre-release checks and tasks * module_release: handles the release process of the module, including versioning and publishing diff --git a/example/module/pdk_update.yml b/example/module/pdk_update.yml new file mode 100644 index 00000000..09a37372 --- /dev/null +++ b/example/module/pdk_update.yml @@ -0,0 +1,17 @@ +name: "PDK Update" + +on: + # Run on a schedule to keep the module template up to date... + schedule: + - cron: "0 4 * * 1" # Mondays at 04:00 UTC + # ...and allow manual runs. + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + pdk_update: + uses: "puppetlabs/cat-github-actions/.github/workflows/module_pdk_update.yml@main" + secrets: "inherit"