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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"name": "Heath Stewart"
},
"metadata": {
"version": "0.6.0"
"version": "0.6.1"
},
"plugins": [
{
Expand Down Expand Up @@ -35,7 +35,7 @@
"name": "security",
"source": "./plugins/security",
"description": "Skills and tools for supply chain security",
"version": "0.2.0"
"version": "0.2.1"
}
]
}
2 changes: 1 addition & 1 deletion plugins/security/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "security",
"description": "Skills and tools for supply chain security",
"version": "0.2.0",
"version": "0.2.1",
"author": {
"name": "Heath Stewart"
},
Expand Down
44 changes: 17 additions & 27 deletions plugins/security/skills/pin-github-actions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,15 @@ description: Run when adding or updating GitHub Actions workflow steps. Pin ever

# Pin GitHub Actions

Pin every `uses:` step in a workflow to an exact commit SHA with the resolved version tag
as a trailing comment so the intent is clear.
Use the script as the source of truth. Run it first; only fall back to manual reasoning
if the script fails or reports unresolved references.

**Format:**
The **skill directory** is the directory containing this SKILL.md file. The
**plugin directory** is two levels above the skill directory (the parent of `skills/`).

```yaml
uses: owner/action@<commit-sha> # vX.Y.Z
```

**Example:**

```yaml
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
## Default flow

## Setup

This skill uses a Python script. The **skill directory** is the directory containing
this SKILL.md file. The **plugin directory** is two levels above the skill directory
(the parent of `skills/`).
Run from the root of the repository whose workflows you want to pin.

Create the plugin venv once if it does not already exist:

Expand All @@ -33,25 +22,26 @@ python -m venv <plugin-dir>/.venv
<plugin-dir>/.venv/bin/pip install -r <skill-dir>/scripts/requirements.txt
```

## Running
If `python` is unavailable, retry the same setup command with `python3`.

Run from the root of the repository whose workflows you want to pin.

Pin all workflows under `.github/workflows/`:
Prefer passing the specific workflow files you already know about:

```bash
<plugin-dir>/.venv/bin/python <skill-dir>/scripts/pin_github_actions.py
<plugin-dir>/.venv/bin/python <skill-dir>/scripts/pin_github_actions.py .github/workflows/ci.yml
```

Pin specific workflow files:
Pin every workflow under `.github/workflows/` only when you need a broader sweep:

```bash
<plugin-dir>/.venv/bin/python <skill-dir>/scripts/pin_github_actions.py .github/workflows/ci.yml
<plugin-dir>/.venv/bin/python <skill-dir>/scripts/pin_github_actions.py
```

Do not read `pin_github_actions.py` or `requirements.txt` unless the command fails.
Do not hand-edit `uses:` lines unless the script cannot complete the change.
Treat a zero exit status as success.

## Rules

- Never reference a mutable tag (e.g., `@v4`, `@main`).
- Never omit the version comment — it is the only human-readable clue to the pinned version.
- Dependabot keeps SHAs current; do not manually update SHAs unless Dependabot cannot reach the action.
- Every non-local `uses:` step must end as `owner/action@<40-char-sha> # vX.Y.Z`.
- Never leave a mutable ref such as `@v4` or `@main`.
- Internal workflow references (`uses: ./.github/workflows/...`) are exempt and are not modified by the script.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path


Expand All @@ -39,6 +40,12 @@
_tag_cache: dict[tuple[str, str, str], str] = {}


@dataclass
class PinResult:
changed: bool = False
unresolved: list[str] = field(default_factory=list)


def _run_ls_remote(repo_url: str, *patterns: str) -> str | None:
try:
result = subprocess.run(
Expand Down Expand Up @@ -130,7 +137,7 @@ def semver_key(t: str) -> tuple[int, ...]:
return best


def _pin_match(m: re.Match) -> str:
def _pin_match(path: Path, unresolved: list[str], m: re.Match) -> str:
"""Return a pinned replacement for a single uses: regex match."""
prefix = m.group(1)
action = m.group(2)
Expand All @@ -148,30 +155,35 @@ def _pin_match(m: re.Match) -> str:
# Already pinned to a SHA — re-resolve using the version in the comment
stripped = comment.strip()
if not stripped.startswith("#"):
return m.group(0) # no version comment; cannot re-resolve
unresolved.append(
f"{path}: {action}@{ref} is pinned to a SHA but missing a version comment"
)
return m.group(0)
version = stripped.lstrip("#").strip()
new_sha = resolve_to_sha(repo_url, version)
if not new_sha:
unresolved.append(f"{path}: could not re-resolve {action} from comment {version}")
return m.group(0)
exact_tag = find_exact_tag(repo_url, version, new_sha)
return f"{prefix}{action}@{new_sha} # {exact_tag}"
else:
sha = resolve_to_sha(repo_url, ref)
if not sha:
print(f" Warning: could not resolve {action}@{ref}", file=sys.stderr)
unresolved.append(f"{path}: could not resolve {action}@{ref}")
return m.group(0)
exact_tag = find_exact_tag(repo_url, ref, sha)
return f"{prefix}{action}@{sha} # {exact_tag}"


def pin_workflow(path: Path) -> bool:
"""Pin all GitHub Actions in a workflow file. Returns True if the file changed."""
def pin_workflow(path: Path) -> PinResult:
"""Pin all GitHub Actions in a workflow file."""
original = path.read_text(encoding="utf-8")
updated = USES_RE.sub(_pin_match, original)
unresolved: list[str] = []
updated = USES_RE.sub(lambda m: _pin_match(path, unresolved, m), original)
if updated != original:
path.write_text(updated, encoding="utf-8")
return True
return False
return PinResult(changed=True, unresolved=unresolved)
return PinResult(unresolved=unresolved)


def main() -> None:
Expand All @@ -188,18 +200,28 @@ def main() -> None:
sys.exit("No workflow files found.")

changed = 0
unresolved_count = 0
for p in paths:
if not p.exists():
print(f"Skipping (not found): {p}", file=sys.stderr)
unresolved_count += 1
continue
print(f"Processing {p} ...", end=" ", flush=True)
if pin_workflow(p):
result = pin_workflow(p)
if result.changed:
print("updated")
changed += 1
elif result.unresolved:
print("needs attention")
else:
print("no changes")
for warning in result.unresolved:
print(f" Warning: {warning}", file=sys.stderr)
unresolved_count += len(result.unresolved)

print(f"\n{changed} file(s) updated.")
if unresolved_count:
sys.exit(f"{unresolved_count} reference(s) still need manual attention.")


if __name__ == "__main__":
Expand Down