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
change ysnqmsqvkvrvlwnsuynpqxkuplqwrqtt
commit 3f162655655f518557955e66b3b45f372a55b519
author Alpha Chen <alpha@kejadlen.dev>
date
parent lytronnn
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 }}