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
31 changes: 23 additions & 8 deletions docs/operate/check-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ order: 210

# Check mode

`mode: check` runs the whole pipeline read-only. Each declared section reads the live state and reports how it differs from the settings file instead of writing anything. The same declared-keys-only rule as apply holds: a key you do not declare is never compared (see [Semantics](../reference/semantics.md)). Check mode changes no settings; the one write it can still perform is delivery of a private report when the `private-report` input is enabled. The issue channels update a marker-labelled issue on the target (`issue` on every run, `issue-on-failure` only to open it on failure or drift, or close it on recovery), and on their first delivery that writes - which for `issue-on-failure` means the first drifting run, never a healthy one - they create that marker label; the artifact channel uploads an encrypted artifact. None of them touches anything else (see the [private repositories guide](private-repositories.md)).
`mode: check` runs the whole pipeline read-only. Each declared section reads the live state and reports how it differs from the settings file instead of writing anything. The same declared-keys-only rule as apply holds: a key you do not declare is never compared (see [Semantics](../reference/semantics.md)).

Check mode changes no settings. The one write it can still perform is delivery of a private report when the `private-report` input is enabled, and that delivery touches only its report issue, its marker label, or its encrypted artifact, nothing else; the [private repositories guide](private-repositories.md#delivering-a-private-report) owns what each channel writes and when.

## Exit behavior

The step's exit code carries the verdict, and the `result` output names it:

- No drift anywhere ends with `result: clean` and exit 0.
- Any drift ends with `result: drift` and exit 1, so a scheduled check turns red the moment reality diverges from the file.
- A section error (a permission denial under the default `on-missing-permission: fail`, or any non-permission API error) marks that section failed and the run failed, exit 1.
- Under `on-missing-permission: warn`, a section the token cannot access is skipped with a warning annotation; if nothing else drifts, the run ends `partial` with exit 0.
| Outcome | `result` | Exit |
|---|---|---|
| No drift anywhere | `clean` | 0 |
| Any drift | `drift` | 1, so a scheduled check turns red the moment reality diverges from the file |
| A section error: a permission denial under the default `on-missing-permission: fail`, or any non-permission API error; the section is marked failed | `failed` | 1 |
| A denied section under `on-missing-permission: warn`, with nothing else drifting | `partial` | 0; [the denial policy](../reference/permissions.md#the-denial-policy) owns the skip |

Apply mode behaves differently on purpose: `mode: apply` exits 0 whether or not it changed anything (`result: applied` covers both), and only a failure exits 1. A green scheduled apply therefore says nothing about whether settings had drifted; a green scheduled check does. In multi-repo mode the worst result across all targets decides the exit code, so one drifted target fails the whole check run (see [multi-repo mode](multi-repo.md)).

Expand Down Expand Up @@ -68,9 +72,20 @@ jobs:
mode: check
```

Read this run as a preview, not a gate. A pull request that changes settings is supposed to differ from the live state, so the step exits 1 exactly when the PR changes something check can verify, and the drift lines list the changes an apply on merge will make. The comparison is the proposed file against the live repository, not against the base branch, so drift that existed before the PR shows up too; that is still the true apply-on-merge delta, but not every line is caused by the PR. Verifiable is the other operative word: write-only values (the LFS toggle, the interaction-limit expiry) are re-asserted by every apply without ever drifting a check, and a ruleset entry that declares fewer fields than the live ruleset carries can narrow it on apply without showing drift, because only declared fields are compared. A broken file still fails usefully: YAML parse errors and malformed section entries fail the run before any comparison. Two caveats: do not make this a required check (a legitimate settings change would be unmergeable), and workflows triggered by pull requests from forks run without secrets, so the token is only available on same-repository branches.
Read this run as a preview, not a gate. A pull request that changes settings is supposed to differ from the live state, so the step exits 1 exactly when the PR changes something check can verify, and the drift lines list the changes an apply on merge will make.

Two phrases in that sentence need unpacking:

- **Live state, not the base branch.** The comparison is the proposed file against the live repository, not against the base branch, so drift that existed before the PR shows up too. That is still the true apply-on-merge delta, but not every line is caused by the PR.
- **Something check can verify.** Write-only values (the LFS toggle, the interaction-limit expiry) are re-asserted by every apply without ever drifting a check. A ruleset entry that declares fewer fields than the live ruleset carries can narrow it on apply without showing drift, because only declared fields are compared.

A broken file still fails usefully: YAML parse errors and malformed section entries fail the run before any comparison.

Two caveats on the workflow: do not make this a required check (a legitimate settings change would be unmergeable), and workflows triggered by pull requests from forks run without secrets, so the token is only available on same-repository branches.

One token note for this workflow. Repository secrets are readable from any workflow a push-access user can edit, so wiring `ADMIN_TOKEN` into a `pull_request` job widens where the write-capable token gets used, even though it grants nothing a pusher could not already reach.

One token note for this workflow. Repository secrets are readable from any workflow a push-access user can edit, so wiring `ADMIN_TOKEN` into a `pull_request` job widens where the write-capable token gets used, even though it grants nothing a pusher could not already reach. Check mode only reads, so the preview works with a second fine-grained PAT whose grants are read-only; on repositories where settings changes are more restricted than push access, give the preview that token instead.
Check mode only reads, so the preview works with a second fine-grained PAT whose grants are read-only. On repositories where settings changes are more restricted than push access, give the preview that token instead.

<!-- BEGIN GENERATED: check-mode-gated-reads (bun run build:action-docs; derived from the section endpoints' accessGrade overrides) -->
The read-only rule has exceptions, each a section to drop from the preview or grant at write:
Expand All @@ -88,7 +103,7 @@ Some declared values have no read-back on GitHub's side, so check mode cannot co
- The whole `check_suite_preferences` section: GitHub exposes no read endpoint for check suite preferences, so check mode issues no request at all for it and apply re-asserts the declared preferences on every run.
- `interaction_limits.expiry`: GitHub accepts a duration but reads back only the computed `expires_at` timestamp, so apply re-arms the limit on every run.
- `webhooks[].config.secret`: GitHub echoes a live secret as `********`, so apply re-sends the declared secret on every run (rotations propagate).
- `rulesets[].bypass_actors` under a token without Administration at write: GitHub returns that field only to a token with write access to the ruleset, so the note says the declared list cannot be judged here and the rest of the ruleset is still compared; a token that can write the ruleset sees the field and compares it as usual, and any write carries the full declaration.
- `rulesets[].bypass_actors` under a token without Administration at write: the note says the declared list cannot be judged here and the rest of the ruleset is still compared ([how a denial surfaces](../reference/permissions.md#how-a-denial-surfaces) says why GitHub withholds the field). A token that can write the ruleset sees the field and compares it as usual, and any write carries the full declaration.
- Secret values in all five secret families (`actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, `agents_secrets`, and per-environment `secrets`): the API returns names only, so check mode verifies existence and apply re-seals and rewrites every declared value on each run. One note per family (or per environment), not per entry.
- `environments[].variables`, `environments[].secrets`, `environments[].deployment_branch_policies`, and `environments[].deployment_protection_rules` declared on an environment that does not exist yet: nothing can be listed until apply creates the environment, so the note says so, while the missing environment and each declared entry apply will create are reported as drift.
- `environments[].deployment_branch_policies` declared on a LIVE environment whose `deployment_branch_policy` does not enable `custom_branch_policies`: the patterns cannot be listed until apply turns the flag on, so the note says so, each declared pattern is reported as drift apply will create once the flag is set, and any pattern already behind the flag reconciles on the following run - while the flag mismatch itself is reported as ordinary environment drift.
Expand Down
47 changes: 40 additions & 7 deletions docs/operate/private-repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ When a run manages repositories more private than its own logs, the details it p

GitHub Actions has no log-level access control. Run logs, step summaries, and uploaded artifacts inherit the repository's visibility, so a public admin repo managing a private target would print that target's slug, its live settings, and its API error bodies where anyone can read them. The only GitHub-ACL-private channel a public run has is another repository the token can reach.

To stop the leak, `private-repos: redact` (the default) hides every private or internal target from the public view. The target's slug becomes a `private repository #N` placeholder, its live values and error bodies become `hidden (private repository)`, and each slug is registered with the runner's secret masker so it cannot resurface in a stray log line. The visibility check fails closed: a repository the probe cannot prove public is redacted anyway. A target equal to `GITHUB_REPOSITORY` is never redacted, because a repository acting on itself discloses nothing new. Set `private-repos: show` only when the run's own logs are already private.
To stop the leak, `private-repos: redact` (the default) hides every private or internal target from the public view:

- **The slug** becomes a `private repository #N` placeholder.
- **Live values and error bodies** become `hidden (private repository)`.
- **Each slug is registered** with the runner's secret masker, so it cannot resurface in a stray log line.

The decision comes down to the policy and what the visibility probe finds:

Expand All @@ -20,9 +24,16 @@ The decision comes down to the policy and what the visibility probe finds:
| probe proves the target private or internal | yes |
| probe cannot determine visibility | yes, redaction fails closed |

The self carve-out exists because a repository acting on itself discloses nothing new. Set `private-repos: show` only when the run's own logs are already private.

## What a redacted run still shows

Redaction hides values, not the shape of the outcome. The public surfaces still carry the safe skeleton of each target. The step summary shows, per target, the overall result (`failed`, `drift`, `partial`, `skipped`, `applied`, `clean`, `snapshot`), each section's key and status, and the HTTP status code on a failed or skipped section; the `repos-result` output carries `{result, source, skipped-sections}` per target, keyed by the placeholder. These are closed enumerations and numeric codes, safe to show, and enough to tell whether the fleet is healthy and which section broke. What they never carry is the slug, a live setting, a desired setting, or an API error message.
Redaction hides values, not the shape of the outcome. The public surfaces still carry the safe skeleton of each target:

- **The step summary** shows, per target, the overall result (`failed`, `drift`, `partial`, `skipped`, `applied`, `clean`, `snapshot`), each section's key and status, and the HTTP status code on a failed or skipped section.
- **The `repos-result` output** carries `{result, source, skipped-sections}` per target, keyed by the placeholder.

These are closed enumerations and numeric codes, safe to show, and enough to tell whether the fleet is healthy and which section broke. What they never carry is the slug, a live setting, a desired setting, or an API error message.

## One seal, every mode

Expand Down Expand Up @@ -62,20 +73,42 @@ The third is to have the run deliver a private report, described next.

## Delivering a private report

`private-report` sends the full unredacted report for each redacted target through a channel whose access control is not the public run; the default, `private-report: none`, sends nothing. Any other channel applies only to redacted targets, and only to those the visibility probe proves private or internal: an unknown visibility is redacted from the public view but excluded from delivery, so the report never reaches a repository that might be public. It is rejected alongside `private-repos: show`. The report mirrors the run's log, delivery stays live in `mode: check`, and a delivery failure only warns; it never changes the target's or the run's result.
`private-report` sends the full unredacted report for each redacted target through a channel whose access control is not the public run. The default, `private-report: none`, sends nothing.

Every other channel follows the same four rules:

- **Only proven-private targets.** A channel applies only to redacted targets, and only to those the visibility probe proves private or internal. An unknown visibility is redacted from the public view but excluded from delivery, so the report never reaches a repository that might be public.
- **Not with `private-repos: show`.** The pairing is rejected.
- **A mirror of the run's log.** It is delivered in `mode: check` as well.
- **A failed delivery only warns.** It never changes the target's or the run's result.

`private-report: issue` posts each target's report to a reused issue on that target repository, where the repository's own access control protects it. Prefer this channel unless your readers lack GitHub access to the targets.

- **Which issue:** found by the marker label, or by its exact title when someone removed the label. Either way it must be one of the action's own reports: the body carries the report heading. A same-titled issue you opened by hand is left alone, unless its body pastes a report verbatim.
- **Whose issue:** the creator does not matter, so a rotated PAT reuses the issue instead of opening a second one. With several reports, an open one wins, then the newest.
- **Each run:** the body is replaced; the issue opens when the target fails or drifts and closes when it is healthy. The run log announces every write with the placeholder, never the slug: `report: updated issue #7 in private repository #1` (or `created`), and `report: created label "settings-as-code-report" in private repository #1` when the delivery creates the marker label. A write that landed before a later step failed is announced the same way, ahead of the warning.
- **Each run:** the body is replaced; the issue opens when the target fails or drifts and closes when it is healthy.
- **What the log says:** every write is announced with the placeholder, never the slug: `report: updated issue #7 in private repository #1` (or `created`), and `report: created label "settings-as-code-report" in private repository #1` when the delivery creates the marker label. A write that landed before a later step failed is announced the same way, ahead of the warning.
- **Permission:** the PAT needs `"Issues"` (read and write) on every target repository, on top of the section permissions.

`private-report: issue-on-failure` is the quiet variant of `issue`. When a target fails (or drifts in check mode) it behaves identically: the reused, marker-labelled report issue is written and opened. On a healthy run it only looks up an open report issue; one left over from an earlier failure is updated with the healthy report and closed, and when there is none, nothing is written - no issue, no label, zero notification noise - and the log says so: `report: nothing to deliver for private repository #1`. A repository that never fails never sees an issue, at the cost that healthy runs' reports are not delivered anywhere; choose `issue` or `artifact` when you need the report mirror on every run.
`private-report: issue-on-failure` is the quiet variant of `issue`. When a target fails (or drifts in check mode) it behaves identically: the reused, marker-labelled report issue is written and opened.

On a healthy run it only looks up an open report issue:

- **One is open,** left over from an earlier failure: it is updated with the healthy report and closed.
- **None is open:** nothing is written, no issue, no label, zero notification noise, and the log says so: `report: nothing to deliver for private repository #1`.

A repository that never fails never sees an issue, at the cost that healthy runs' reports are not delivered anywhere. Choose `issue` or `artifact` when you need the report mirror on every run.

Two caveats on the quiet variant:

- **A declared `labels` section still gets the marker.** The `settings-as-code-report` marker label is injected into it, so the apply creates the label even on healthy repositories (a label, not a notification).
- **A removed marker label hides an open issue.** If someone removes the label while a report issue is open, the healthy path cannot find the issue, so it stays open until the next failing run recreates the label and reclaims it.

Everything else matches `issue`: the same delivery gate, the same Issues grant (the healthy lookup is a read; the open and close are writes), and delivery still runs in `mode: check`.

Two caveats on the quiet variant. If the settings declare a `labels` section, the `settings-as-code-report` marker label is still injected into it, so the apply creates the label even on healthy repositories (a label, not a notification). And if someone removes the marker label while a report issue is open, the healthy path cannot find the issue, so it stays open until the next failing run recreates the label and reclaims it. Everything else matches `issue`: the same delivery gate, the same Issues grant (the healthy lookup is a read; the open and close are writes), and delivery still runs in `mode: check`.
`private-report: artifact` concatenates the report for every proven-private target into one document, encrypts it to an age recipient, and uploads it as the workflow artifact `settings-as-code-private-report` (file `private-report.md.age`). Use it when the people who need the report cannot be given repository access, since the archive travels with the run rather than living in the target repo.

`private-report: artifact` concatenates the report for every proven-private target into one document, encrypts it to an age recipient, and uploads it as the workflow artifact `settings-as-code-private-report` (file `private-report.md.age`). Use it when the people who need the report cannot be given repository access, since the archive travels with the run rather than living in the target repo. This channel needs the Actions artifact service, so it does not work on GitHub Enterprise Server (the `@actions/artifact` client has no GHES backend): there the run warns and uploads nothing.
This channel needs the Actions artifact service, so it does not work on GitHub Enterprise Server (the `@actions/artifact` client has no GHES backend): there the run warns and uploads nothing.

Access control on the artifact channel is key possession, so the key setup matters. Generate a keypair on your own machine; the private key must never touch GitHub:

Expand Down
Loading
Loading