Align skills with CI hardening from ramekin and ketchup
Capture patterns applied across both repos: mandatory permission
comments, env var naming convention for expression indirection,
zizmor as a standard CI job, and zizmor ignore with justification.
Update rust-binary templates to use SHA pinning and
persist-credentials: false.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/ai/skills/github-actions/SKILL.md b/ai/skills/github-actions/SKILL.md
new file mode 100644
index 00000000..73ab73da
--- /dev/null
+++ b/ai/skills/github-actions/SKILL.md
@@ -0,0 +1,183 @@
+---
+name: github-actions
+description: Use when writing, reviewing, or debugging GitHub Actions workflows — covers security hardening, concurrency, job structure, permissions, and common patterns
+---
+
+# GitHub Actions
+
+*This is a self-improving skill — see the `self-improving-skills` skill.*
+
+## Security
+
+**Default-deny permissions.** Set `permissions: {}` at the workflow level,
+then grant the minimum each job needs. **Every permission line gets a
+comment explaining why** — no bare permissions:
+
+```yaml
+permissions: {}
+
+jobs:
+ test:
+ permissions:
+ contents: read # checkout
+ packages: write # push container image to GHCR
+```
+
+**Pin actions to full commit SHAs**, not tags. Tags are mutable — a
+compromised action can be re-tagged. Add a version comment for humans:
+
+```yaml
+- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+```
+
+**Disable credential persistence** on checkout to prevent tokens from
+leaking to later steps:
+
+```yaml
+- uses: actions/checkout@<sha> # v6
+ with:
+ persist-credentials: false
+```
+
+**Indirect environment variable expansion.** Never interpolate
+expressions directly in `run:` scripts — an attacker-controlled value
+(branch name, PR title) can inject shell commands. Assign expressions to
+env vars first:
+
+```yaml
+# wrong — injectable
+- run: echo "${{ github.event.pull_request.title }}"
+
+# correct — safe expansion
+- env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: echo "$PR_TITLE"
+```
+
+Name the env var after the expression path, uppercased with underscores:
+`${{ steps.meta.outputs.version }}` → `STEPS_META_OUTPUTS_VERSION`,
+`${{ needs.build.outputs.image }}` → `NEEDS_BUILD_OUTPUTS_IMAGE`.
+
+## Concurrency
+
+**Cancel in-progress PR runs.** Use the workflow name and ref as the
+concurrency key, and only cancel for PRs (not pushes to main):
+
+```yaml
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+```
+
+**Serialize deploys.** Use a fixed group name with `cancel-in-progress:
+true` so only the latest deploy runs:
+
+```yaml
+concurrency:
+ group: fly-deploy
+ cancel-in-progress: true
+```
+
+## Job structure
+
+**Separate test, build, and deploy.** Use `needs:` to express
+dependencies and `if:` to gate jobs on context:
+
+```yaml
+jobs:
+ test:
+ # runs on all pushes and PRs
+ build:
+ needs: test
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ deploy:
+ needs: build
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+```
+
+**Pass data between jobs with outputs.** Declare outputs in the
+producing job and reference them downstream. Assign the output to an
+env var rather than interpolating in `run:`:
+
+```yaml
+build:
+ outputs:
+ image: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }}
+
+deploy:
+ needs: build
+ steps:
+ - run: flyctl deploy --image ${IMAGE}
+ env:
+ IMAGE: ${{ needs.build.outputs.image }}
+```
+
+## Environments
+
+Use GitHub environments for deploy protection rules. For PR previews,
+key the name on the PR number:
+
+```yaml
+preview:
+ environment:
+ name: pr-${{ github.event.number }}
+ url: ${{ steps.deploy.outputs.url }}
+```
+
+## Zizmor
+
+Static analysis for GitHub Actions. Catches template injection,
+excessive permissions, unpinned actions, credential leaks, and more.
+Run `zizmor --help` for full options.
+
+**Always add a zizmor audit job to CI.** Every repo with GitHub Actions
+workflows should include one:
+
+```yaml
+zizmor:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read # checkout
+ security-events: write # upload SARIF to code scanning
+ steps:
+ - uses: actions/checkout@<sha> # v6
+ with:
+ persist-credentials: false
+ - uses: zizmorcore/zizmor-action@<sha>
+```
+
+Local usage:
+
+```bash
+zizmor . # audit current repo
+zizmor .github/workflows/ci.yml # single file
+zizmor --fix . # auto-fix safe findings
+zizmor --pedantic . # include code smells
+```
+
+Configure with `zizmor.yml` in repo root or `.github/`. Ignore inline
+with a justification after `--`:
+
+```yaml
+on:
+ workflow_run: # zizmor: ignore[dangerous-triggers] -- scoped to main branch, checks conclusion
+```
+
+## Renovate integration
+
+Pin action SHAs and let Renovate update them. The `github-actions`
+manager detects `uses:` lines automatically — no extra config needed.
+
+## Quick reference
+
+| What | How |
+|---|---|
+| Set step output | `echo "key=value" >> "$GITHUB_OUTPUT"` |
+| Set env for later steps | `echo "KEY=value" >> "$GITHUB_ENV"` |
+| Conditional job | `if: github.ref == 'refs/heads/main'` |
+| Conditional step | `if: github.event.action != 'closed'` |
+| Job dependency | `needs: [test, lint]` |
+| Job output | `outputs: { version: ${{ steps.x.outputs.version }} }` |
+| Consume job output | `${{ needs.build.outputs.version }}` |
+| Cancel previous runs | `cancel-in-progress: true` in `concurrency:` |
+| Required permissions | `permissions:` block per job |
diff --git a/ai/skills/rust-binary/SKILL.md b/ai/skills/rust-binary/SKILL.md
index b1dbf8a3..74a342a0 100644
--- a/ai/skills/rust-binary/SKILL.md
+++ b/ai/skills/rust-binary/SKILL.md
@@ -264,15 +264,23 @@ jobs:
ci:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@SHA # v4
+ with:
+ persist-credentials: false
- run: rustup component add clippy rustfmt llvm-tools
- run: cargo install grcov cargo-mutants just
- - uses: astral-sh/setup-uv@v6
+ - uses: astral-sh/setup-uv@SHA # v6
- run: cargo fmt --check
- run: just clippy coverage
- run: just mutants
```
+Pin all actions to commit SHAs with a version comment (`@<sha> # v4`). Resolve the SHA for each tag at generation time:
+
+```bash
+git ls-remote https://github.com/<owner>/<repo> <tag> | cut -f1
+```
+
`cargo fmt --check` instead of `just fmt` — CI should fail on unformatted code, not silently fix it.
The `setup-uv` step is required because hegeltest uses `uv` to manage its Hypothesis backend.
diff --git a/ai/skills/rust-binary/release.md b/ai/skills/rust-binary/release.md
index 6ba4f6aa..ccb6a9db 100644
--- a/ai/skills/rust-binary/release.md
+++ b/ai/skills/rust-binary/release.md
@@ -23,8 +23,9 @@ jobs:
|| github.event.workflow_run.conclusion == 'success'
runs-on: macos-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@SHA # v4
with:
+ persist-credentials: false
fetch-depth: 0
- name: Calculate version
@@ -83,10 +84,12 @@ Add a `dotslash` job to `release.yml` after the build job:
needs: build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@SHA # v4
+ with:
+ persist-credentials: false
- name: Generate DotSlash file
- uses: facebook/dotslash-publish-release@v1
+ uses: facebook/dotslash-publish-release@SHA # v1
with:
config: .github/dotslash-config.json
tag: v${{ needs.build.outputs.version }}