Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/hooks/_parse_input.sh
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions .claude/hooks/no-main-commits.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .claude/hooks/no-pr-merge.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .claude/hooks/no-push.sh
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .claude/hooks/no-rm.sh
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}
99 changes: 99 additions & 0 deletions .github/workflows/module_pdk_update.yml
Original file line number Diff line number Diff line change
@@ -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"
170 changes: 126 additions & 44 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,133 @@
# 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 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
`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/`.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions example/module/pdk_update.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading