Skip to content
Open
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
116 changes: 116 additions & 0 deletions src/docs/Coding-Standards/GitHub-Actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,122 @@ jobs:
contents: read # checkout only
```

## Use GitHub App installation tokens as the default write identity

When a workflow needs to write — creating commits, releases, comments, statuses,
or pull requests — default to a **GitHub App installation token**, not a PAT and
not `github.token`, unless a documented exception requires otherwise.

Use the right token type for the job:

| Token | Source | Tied to user | Cross-repo | Caller-scoped |
| --- | --- | --- | --- | --- |
| `github.token` (`GITHUB_TOKEN`) | Automatically injected | No (GitHub Actions bot) | No | Yes — workflow `permissions:` block |
| PAT (classic or fine-grained) | Stored in repo/org secret | Yes | Yes (if scoped) | No — fixed at creation |
| GitHub App installation token | Minted per run (`actions/create-github-app-token`) | No (app bot identity) | Yes (if installed multi-repo) | Yes — `repositories:` + `permission-*` inputs |

Prefer a GitHub App token when:

- commits and releases must be attributed to a bot identity rather than a person;
- a reusable workflow needs write access, including calls that originate from
fork-driven pull request flows;
- per-job permission scoping must be controlled by the called workflow itself.

### `permissions:` scopes `github.token` only

The workflow or job `permissions:` block controls only the default
`github.token`/`GITHUB_TOKEN`. It does **not** constrain a GitHub App
installation token minted by `actions/create-github-app-token`.

Treat these as separate controls:

- `permissions:` limits what the default workflow token can do.
- `repositories:` + `permission-*` inputs limit what the minted app token can do.

If a minted app token omits `repositories:` and `permission-*`, it inherits the
full installation scope — every installed repository and every granted
permission. That shape is forbidden.

### Mint app tokens with minimum scope every time

Every token mint must declare both repository and permission scope explicitly.

```yaml
- name: Mint a minimally scoped app token
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.GitHubAppClientId }}
private-key: ${{ secrets.GitHubAppPrivateKey }}
repositories: ${{ github.event.repository.name }}
permission-contents: read
permission-pull-requests: write
```

Rules:

- **Always set `repositories:`.** Default to the current repository
(`${{ github.event.repository.name }}`); widen only when cross-repository access
is an explicit requirement.
- **Always set `permission-*` inputs.** Request only the scopes the job uses.
- **Never mint an unscoped token.** A token with no repository or permission
narrowing violates least privilege.

### Inject app tokens at step scope, not job scope

A token should exist only where it is consumed.

- **Prefer step-level `env:`** so one step sees the token.
- **Avoid job-level `env:`** for tokens; it exposes the token to every step in
the job, including third-party actions.

```yaml
# Correct — token visible only to this step.
- name: Update labels
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
shell: pwsh
run: gh issue edit 123 --add-label ready
```

```yaml
# Avoid — token is injected into every step in the job.
jobs:
update:
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
```

Use `GH_TOKEN` for the GitHub CLI. `gh` checks `GH_TOKEN` first, then
`GITHUB_TOKEN`; setting `GH_TOKEN` avoids accidentally replacing the default
token other actions may rely on.

### Prefer environment-based token passing for actions and composite actions

When an action supports both a named token input (for example `with: token:`) and
environment-based auth, prefer the environment variable (`GH_TOKEN`) path.

```yaml
# Preferred — token passed through environment.
- name: Run publish action
uses: org/publish-action@<sha> # vX.Y.Z
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
```

```yaml
# Avoid when an env-based option exists — named token input can appear in debug logs.
- name: Run publish action
uses: org/publish-action@<sha> # vX.Y.Z
with:
token: ${{ steps.app-token.outputs.token }}
```

With `ACTIONS_STEP_DEBUG=true`, named input values can appear in debug output.
Runner masking protects the raw secret string, but not every transformed form. If
an action requires a named token input, verify the action masks it at startup
(`core.setSecret(inputs.token)`) before it logs any input.

## Authenticate with OIDC, not long-lived secrets

- **Use OIDC federation** (`id-token: write`) to obtain short-lived cloud
Expand Down