---
name: forge-github-actions
description: GitHub Actions workflow discipline. SHA-pinned third-party actions, scoped permissions per job, OIDC federation over long-lived cloud credentials, dependency caching, concurrency cancellation on PR, no secrets in `run:` lines, no `pull_request_target` running fork code. Contains ready-to-paste lint+test+build+deploy workflow. Use when writing or auditing `.github/workflows/*.yml`.
license: MIT
---

# forge-github-actions

You are writing CI workflows. Default agent-written GitHub Actions pin nothing (`uses: actions/checkout@main`), grant the workflow full `contents: write` "to be safe," skip caching, and echo secrets for debugging. Each pattern is a documented supply-chain or leak vector. This skill exists to fix them.

The mental model: **a workflow runs with a token.** Treat that token like it has the ssh key for your prod servers, because in many cases it effectively does.

## Quick reference (the things you must never ship)

1. `uses: actions/checkout@v4` (or any tag, for third-party actions).
2. `permissions: write-all` or no `permissions:` block at all.
3. `${{ secrets.X }}` interpolated into a `run:` command line.
4. `echo "${{ secrets.X }}"` anywhere.
5. `pull_request_target` running the fork's code.
6. AWS / GCP / Azure access keys stored as long-lived secrets when OIDC federation is available.
7. `on: [push]` running for every commit on every branch.
8. No `concurrency:` block on PR builds.
9. `actions/setup-*` without `cache:` enabled.
10. Self-hosted runner running untrusted PR code.

## Hard rules

### Action pinning

**1. Pin third-party actions to a commit SHA, not a tag or branch.** `uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11` not `uses: actions/checkout@v4`. Tags can be moved; SHAs cannot.

**2. First-party `actions/*` from GitHub can use major-version tags safely.** Governed differently. Pinning to SHA is still better.

**3. Use Dependabot to keep action SHAs current.** Manual SHA pinning rots; Dependabot opens PRs with SHA bumps.

```yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
```

### Permissions

**4. `permissions: {}` at the top of every workflow.** Then grant only what individual jobs need. Default of "everything" is the most common over-grant.

```yaml
permissions: {}

jobs:
  test:
    permissions:
      contents: read
    # ...

  release:
    permissions:
      contents: write    # only this job can write
      id-token: write    # for OIDC federation
    # ...
```

**5. `contents: read` is the default for most jobs.** Bump to `write` only for jobs that genuinely commit, tag, or release.

**6. `id-token: write` only for OIDC federation steps.** Most powerful permission; grants ability to assume cloud IAM roles. Never blanket-grant.

### Secrets

**7. Never echo a secret.** GitHub redacts known secret values in logs, but only AFTER first occurrence is observed.

**8. Never pass secrets via `run: command --token ${{ secrets.X }}`.** Command lines appear in process tables.

```yaml
# BAD
- run: deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}

# GOOD
- run: deploy.sh
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
```

**9. Repository secrets (org-shared or repo-scoped). Environment secrets for production-only values with required reviewers.**

**10. OIDC federation over long-lived cloud credentials.** AWS, GCP, Azure all support OIDC. A short-lived federated token beats a stored access key.

```yaml
- uses: aws-actions/configure-aws-credentials@SHA
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
    aws-region: us-east-1
# no AWS_ACCESS_KEY_ID env at all
```

### Triggers

**11. `pull_request_target` is dangerous and rarely needed.** Runs with base repo's secrets against forked code. Only use for trusted automation (labeling) that does NOT execute fork code.

**12. `workflow_dispatch` for manual runs. Document required inputs.**

**13. `schedule:` cron is UTC. Note this in a comment.**

**14. Avoid `on: [push]` for everything. Use path filters or branch filters.** A 10-minute CI on a docs-only commit wastes minutes.

```yaml
on:
  push:
    branches: [main]
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'docs/**'
```

### Caching

**15. Cache language dependencies.** `actions/setup-node`, `actions/setup-python`, etc. all support `cache:` out of the box. Use it.

```yaml
- uses: actions/setup-node@SHA
  with:
    node-version: 22
    cache: npm    # or pnpm, yarn
```

**16. Cache key includes the lockfile hash. Restore-keys for partial reuse.**

```yaml
- uses: actions/cache@SHA
  with:
    path: ~/.cache/playwright
    key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-playwright-
```

**17. Cache build outputs only when build is expensive and deterministic.**

### Concurrency

**18. `concurrency:` cancels in-progress runs for the same group on new pushes.**

```yaml
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
```

Massively cuts CI on rapid pushes.

**19. For deploy workflows, `cancel-in-progress: false`.** Cancelling mid-deploy is worse than queuing.

### Matrix and parallelism

**20. Matrix builds parallelize across OS, language version, etc.** Define explicitly; do not let it balloon.

**21. `fail-fast: false` for cross-version test matrices.** You want to see all failures, not just the first.

**22. Job parallelism is free up to runner limits.** Independent jobs run in parallel automatically. Use `needs:` only for genuine dependencies.

### Self-hosted runners

**23. Self-hosted runners do not run untrusted PR code by default.** "Require approval for all outside collaborators" must be on.

**24. Self-hosted runners are ephemeral if possible.** Fresh containers per job; persistent runners accumulate state and risk.

### Logging and outputs

**25. `::set-output::` (legacy) is deprecated. Use `$GITHUB_OUTPUT` env file.**

```yaml
- run: echo "version=1.2.3" >> $GITHUB_OUTPUT
  id: meta
- run: echo "Version is ${{ steps.meta.outputs.version }}"
```

**26. Step summary for human-readable output.**

```yaml
- run: echo "## Test results" >> $GITHUB_STEP_SUMMARY
```

## Common AI-output patterns to reject

| Pattern | Why dangerous | Fix |
| --- | --- | --- |
| `uses: actions/checkout@master` | Tag/branch can move | Pin to SHA |
| `permissions: write-all` | All scopes granted | `permissions: {}` + per-job grants |
| `run: deploy.sh ${{ secrets.X }}` | Secret in process table | Pass via `env:` |
| `echo "${{ secrets.X }}"` | First occurrence leaks | Cut |
| `pull_request_target` running fork code | Secrets given to fork's code | Use `pull_request` only, or restrict to trusted automation |
| AWS access keys as long-lived secrets | Stolen key = months of pain | OIDC federation, short-lived |
| `on: [push]` for everything | CI on docs-only commits | Path filters, branch filters |
| No `concurrency:` on PR builds | Old runs waste minutes | `cancel-in-progress: true` |
| `actions/setup-node` no `cache:` | Re-installs deps every time | `cache: npm` |
| `imagePullPolicy: Always` with `:latest` | Wrong layer; in Dockerfile | (see forge-dockerfile) |

## Worked example: complete CI workflow

```yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'docs/**'

permissions: {}

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-typecheck:
    name: Lint + typecheck
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11    # v4
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm run lint
      - run: npx tsc --noEmit

  test:
    name: Test
    runs-on: ubuntu-latest
    permissions:
      contents: read
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
        with: { node-version: 22, cache: npm }
      - run: npm ci --ignore-scripts
      - run: npm run migrate
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test
      - run: npm test
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test

  build-image:
    name: Build image
    runs-on: ubuntu-latest
    needs: [lint-and-typecheck, test]
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      id-token: write       # OIDC for AWS ECR
      packages: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push
          aws-region: us-east-1
      - uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
        id: ecr
      - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.ecr.outputs.registry }}/orders-api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
```

What this demonstrates: `permissions: {}` at top (rule 4); per-job grants (rule 4); concurrency cancellation on PR (rule 18); path filters skip docs commits (rule 14); SHA-pinned third-party actions, major-version OK for `actions/*` (rules 1-2); dependency caching via `cache: npm` (rule 15); Postgres service for integration tests (paired with `forge-tests`); OIDC federation for ECR push - no long-lived AWS keys (rule 10); secrets never in `run:` command lines.

## Workflow

When writing a GitHub Actions workflow:

1. **Decide the trigger and scope.** `push` on a branch, `pull_request`, scheduled, manual.
2. **Add `permissions: {}` at the top.** Grant only what's needed per job.
3. **Pin every external action to a SHA.**
4. **Add caching for dependencies.**
5. **Set `concurrency:` for PR builds.**
6. **Write the fastest possible check first (lint, format) before slow tests.** Fail fast.
7. **Test a single matrix entry locally with `act` before pushing if reasonable.**

## Verification

```bash
bash skills/infra/forge-github-actions/verify/check_actions.sh path/to/.github/workflows/*.yml
```

Flags: unpinned third-party actions, `permissions: write-all`, secrets in `run:` command lines, echoed secrets.

## When to skip this skill

- Other CI providers (GitLab CI, CircleCI, Buildkite) - same principles, different syntax. Write a parallel skill.
- Pre-existing internal CI templates you cannot modify.

## Related skills

- [`forge-dockerfile`](../forge-dockerfile/SKILL.md) - the build the CI runs.
- [`forge-secrets`](../../security/forge-secrets/SKILL.md) - the secrets the CI reads.
- [`forge-tests`](../../testing/forge-tests/SKILL.md) - the test discipline the CI enforces.
