From 89dec6f25ccb27465eef4afcddb4d2d2452ac2e4 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:54:57 -0400 Subject: [PATCH 1/2] docs(operate): reshape the check-mode, private-repositories, snapshot, and troubleshooting pages for skimming Every paragraph and list item over the 70-word cap, and every paragraph over three sentences, moved into the device that shows its content fastest: a result-and-exit table in check-mode, bold lead-in bullets for the redaction effects, the report-channel rules, the quiet-variant caveats, the canonical snapshot order, and the 403 and debug-trace facts, numbered steps for the bundle rebuild, and split paragraphs elsewhere. Headings and their anchors are unchanged on all four pages. Five facts owned by docs/reference/permissions.md (the warn policy, the preflight probe's active-section rule, the 404-for-Administration rule, the hidden bypass_actors field, the GITHUB_TOKEN limitation) and one owned by private-repositories.md (what the private-report channels write in check mode) are now a link to the owner's heading with one local clause where the symptom needs it. No fact was lost or added; a cross-model review compared the old and new pages fact by fact and found both lists empty on every page. --- docs/operate/check-mode.md | 31 +++++++++++++----- docs/operate/private-repositories.md | 47 ++++++++++++++++++++++---- docs/operate/snapshot.md | 18 ++++++++-- docs/operate/troubleshooting.md | 49 +++++++++++++++++++++++----- 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/docs/operate/check-mode.md b/docs/operate/check-mode.md index 8c7513419..aeda1ca9e 100644 --- a/docs/operate/check-mode.md +++ b/docs/operate/check-mode.md @@ -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 | that section and the run are marked 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)). @@ -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. The read-only rule has exceptions, each a section to drop from the preview or grant at write: @@ -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. diff --git a/docs/operate/private-repositories.md b/docs/operate/private-repositories.md index 6133e48de..af124a76c 100644 --- a/docs/operate/private-repositories.md +++ b/docs/operate/private-repositories.md @@ -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: @@ -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 @@ -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: diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index dc09b02bc..f4c15961e 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -62,9 +62,19 @@ Reading it top to bottom: - The first line pins the published schema, so an editor validates and autocompletes the file. - Then one comment line per note, in code-point order within each section: a secret whose value GitHub never reveals, a section the snapshot cannot read back, a section with nothing live to declare. The same notes appear as annotations on the run, exactly as a check run would print them, so a note names a secret or a webhook by its name or URL and never by a value. -- The document follows in the one canonical order every rendered document has (`mode: render` writes the same one): the sections in the order the action applies them, each section's keys as the schema declares them, the entries of every keyed list sorted by their identity (a label's `name`, a webhook's `config.url`, a deploy key's `title`; `branches` keep their order, since GitHub applies overlapping wildcard rules in creation order, pinned environments keep theirs, since it is the pin rank, and the next paragraph names the lists with no identity), every knobbed list section (the sections [the undeclared policy](../reference/undeclared-policy.md) counts) in its `{_undeclared, entries}` wrapper form with the section's default policy spelled out, and `environments`, `branches`, and `workflows` as bare lists, since their wrapper takes no policy. An apply from the file does exactly what the header says, and two snapshots of one repository are the same bytes. The lists left as written keep the order GitHub lists them in: the mapping lists with no identity (`bypass_actors`, `reviewers`), the values inside a rule's `parameters` (`required_status_checks`), and every scalar list (`topics`, a webhook's `events`, a ruleset's `include` patterns). Byte-identical output holds for them while GitHub returns each in a stable order, as it does today; the one scalar list a section sorts itself is `force_push_bypassers`. +- The document follows in the one canonical order every rendered document has (`mode: render` writes the same one), stated below. An apply from the file does exactly what the header says, and two snapshots of one repository are the same bytes. - Nothing in the file names the moment it was taken: the run's notice (`snapshot taken 2026-09-11T06:17:00.000Z`) and the step summary carry it, so a snapshot of an unchanged repository rewrites the file byte for byte and a diff between two snapshots shows only what changed on GitHub. +The canonical order, outermost first: + +- **Sections:** in the order the action applies them. +- **Each section's keys:** as the schema declares them. +- **Every knobbed list section** (the sections [the undeclared policy](../reference/undeclared-policy.md) counts): in its `{_undeclared, entries}` wrapper form with the section's default policy spelled out. `environments`, `branches`, and `workflows` are bare lists, since their wrapper takes no policy. +- **The entries of every keyed list:** sorted by their identity (a label's `name`, a webhook's `config.url`, a deploy key's `title`). Two lists keep their order instead: `branches`, since GitHub applies overlapping wildcard rules in creation order, and pinned environments, since it is the pin rank. +- **The lists with no identity:** left as written, in the order GitHub lists them in. Those are the mapping lists with no identity (`bypass_actors`, `reviewers`), the values inside a rule's `parameters` (`required_status_checks`), and every scalar list (`topics`, a webhook's `events`, a ruleset's `include` patterns). + +Byte-identical output holds for the lists left as written while GitHub returns each in a stable order, as it does today; the one scalar list a section sorts itself is `force_push_bypassers`. + ## The `$NAME` placeholders GitHub never returns a secret's value, so the file cannot hold one. The snapshot writes a reference instead, and the note tells you which variable to export before an apply or check runs the file: @@ -176,7 +186,11 @@ jobs: Copy the directory in as your `repos-dir` to bring the fleet under management, one reviewable file per repository. The step summary lists every target with its result and file, then one section table per target, and the `repos-result` output carries the per-repository results as JSON. -Private and internal targets are redacted by default, through the same seal a multi-repo apply closes its targets with (see [private repositories](private-repositories.md#one-seal-every-mode)): their file is written like the others, but the public surfaces know them only as `private repository #N`, with the file name, the notes, and every detail hidden; a private target that fails gets the same one-line annotation a fleet target gets. In the `snapshot-file` form a target other than the current repository is redacted the same way, as `private repository #1`. Their notes are in their file header; there is no report channel in snapshot mode. The uploaded `snapshots` artifact therefore holds those targets' full documents in the clear, and an artifact inherits the admin repository's visibility, so on a public admin repository encrypt it or skip the upload (see [what redaction does and does not protect](private-repositories.md#what-redaction-does-and-does-not-protect)). +Private and internal targets are redacted by default, through the same seal a multi-repo apply closes its targets with (see [private repositories](private-repositories.md#one-seal-every-mode)). Their file is written like the others, but the public surfaces know them only as `private repository #N`, with the file name, the notes, and every detail hidden. A private target that fails gets the same one-line annotation a fleet target gets. + +In the `snapshot-file` form a target other than the current repository is redacted the same way, as `private repository #1`. + +Their notes are in their file header; there is no report channel in snapshot mode. The uploaded `snapshots` artifact therefore holds those targets' full documents in the clear, and an artifact inherits the admin repository's visibility, so on a public admin repository encrypt it or skip the upload (see [what redaction does and does not protect](private-repositories.md#what-redaction-does-and-does-not-protect)). ## What a denial does diff --git a/docs/operate/troubleshooting.md b/docs/operate/troubleshooting.md index 3f30d9bb6..03a7f1bb5 100644 --- a/docs/operate/troubleshooting.md +++ b/docs/operate/troubleshooting.md @@ -10,19 +10,30 @@ Errors from this action are written to carry their own fix: the failing request, What you see: an error annotation naming the section, the denied request with its HTTP status, and advice starting "To fix, grant" that names the exact fine-grained PAT permission the section needs. -What it means: the token lacks that section's grant. Each section's permission is listed in the [Sections table](../reference/sections.md). The default `GITHUB_TOKEN` cannot hold most of them (Administration in particular), so a missing or under-scoped PAT is the usual cause. +What it means: the token lacks that section's grant. Each section's permission is listed in the [Sections table](../reference/sections.md). A missing or under-scoped PAT is the usual cause; [what to grant](../reference/permissions.md#what-to-grant) says why the default `GITHUB_TOKEN` is not enough. -What to do: edit the PAT's permissions as the message says (see [Token permissions](../reference/permissions.md)). If you would rather skip sections the token cannot reach, set `on-missing-permission: warn`: denied sections are skipped with a warning instead of failing the run, and when nothing else drifts or fails the result is `partial` and the run stays green. The `required-sections` input names sections that must still fully apply even under `warn`. +What to do: edit the PAT's permissions as the message says (see [Token permissions](../reference/permissions.md)). To skip sections the token cannot reach instead, set `on-missing-permission: warn`, with `required-sections` naming the ones that must still fully apply; [the denial policy](../reference/permissions.md#the-denial-policy) owns both inputs. -One related surprise: under the default `on-missing-permission: fail`, an apply run probes every active section (each declared section the `sections` input selects; all of them when that input is unset) read-only before writing anything. If any probe is denied you get error annotations prefixed `preflight:` and nothing at all is applied, by design; the API has no transactions, so the barrier prevents a half-applied repository. See [Semantics](../reference/semantics.md). +One related surprise: under the default `on-missing-permission: fail`, an apply run probes every active section read-only before writing anything ([the denial policy](../reference/permissions.md#the-denial-policy) says which sections are active). If any probe is denied you get error annotations prefixed `preflight:` and nothing at all is applied, by design; the API has no transactions, so the barrier prevents a half-applied repository. See [the preflight barrier](../reference/semantics.md#the-preflight-barrier). ## A 404 for something that exists -Fine-grained tokens surface a missing Administration permission as a 404, not a 403, on admin endpoints; GitHub hides the resource rather than admit it exists. The action treats both statuses as permission errors, and a 404 denial appends "(a 404 here can also mean the resource does not exist)". Check the grant first. If the grant is right, check the resource: the repository slug, a workflow file path. +A 404 has three readings. -Where GitHub's response body names the definite meaning, the action reads the body instead of the status: the branch protection PUT's 404 "Branch not found" fails the `branches` section with "create the branch, or remove it from the settings file". That is never a permission denial, so `on-missing-permission: warn` does not skip it, and the outcome is the same whether or not the token also holds Contents (with Contents the section reads the missing branch off its probe and fails before the PUT). +**A missing grant.** Fine-grained tokens surface a missing Administration permission as a 404, not a 403, on admin endpoints, because GitHub hides the resource rather than admit it exists; [how a denial surfaces](../reference/permissions.md#how-a-denial-surfaces) owns the rule. A 404 denial appends "(a 404 here can also mean the resource does not exist)". -On the `secret_scanning_custom_patterns` endpoints a 404 has a third reading, which the denial message carries: secret scanning is not enabled for the repository (it requires GitHub Advanced Security on private repositories). Enabling scanning and declaring patterns cannot land in ONE apply under the default `on-missing-permission: fail`: the preflight barrier probes every active section read-only before anything is written, so the patterns list 404s and aborts the run before the `repository` section could enable scanning via `security_and_analysis`. Either enable scanning first (a separate run, or by hand in the repository's security settings), or set `on-missing-permission: warn` for the bootstrap run - the first apply then enables scanning and skips the patterns section with a warning, and the next apply converges. +Check the grant first; if the grant is right, check the resource: the repository slug, a workflow file path. + +**A resource that is really missing.** Where GitHub's response body names the definite meaning, the action reads the body instead of the status. The branch protection PUT's 404 "Branch not found" fails the `branches` section with "create the branch, or remove it from the settings file". + +That is never a permission denial, so `on-missing-permission: warn` does not skip it. The outcome is the same whether or not the token also holds Contents: with Contents the section reads the missing branch off its probe and fails before the PUT. + +**Secret scanning not enabled.** On the `secret_scanning_custom_patterns` endpoints a 404 has a third reading, which the denial message carries: secret scanning is not enabled for the repository (it requires GitHub Advanced Security on private repositories). + +Enabling scanning and declaring patterns cannot land in ONE apply under the default `on-missing-permission: fail`. The preflight barrier above probes every active section read-only before anything is written, so the patterns list 404s and aborts the run before the `repository` section could enable scanning via `security_and_analysis`. Two ways out: + +- Enable scanning first: a separate run, or by hand in the repository's security settings. +- Set `on-missing-permission: warn` for the bootstrap run: the first apply then enables scanning and skips the patterns section with a warning, and the next apply converges. ## A 412 on secret scanning custom patterns @@ -34,7 +45,10 @@ What to do: re-run the workflow. The fresh run reads the current versions and co ## A 403 that is not about a grant -Two other things arrive as 403. First, rate limiting: both the primary limit and secondary (abuse) limits can be delivered as 403. The action recognizes these by the API's own message and reports them as rate limits, never as missing permissions. Second, feature policies: on a few endpoints a 403 means something other than the token. An org- or enterprise-managed policy can lock the Actions cache limits, code scanning default setup needs Advanced Security on private repositories, and Git LFS can be disabled account-wide. For Git LFS the denial message itself carries a note saying so; for the others the caveat lives in that section's notes on the [coverage page](../reference/coverage.md#supported). +Two other things arrive as 403: + +- **Rate limiting.** Both the primary limit and secondary (abuse) limits can be delivered as 403. The action recognizes these by the API's own message and reports them as rate limits, never as missing permissions. +- **Feature policies.** On a few endpoints a 403 means something other than the token: an org- or enterprise-managed policy can lock the Actions cache limits, code scanning default setup needs Advanced Security on private repositories, and Git LFS can be disabled account-wide. For Git LFS the denial message itself carries a note saying so; for the others the caveat lives in that section's notes on the [coverage page](../reference/coverage.md#supported). ## Rate limited @@ -71,8 +85,25 @@ The message says "The token was rejected as invalid or expired". This is not a p ## Turning on debug logging -Every API call the action makes is traced as a debug line: method, path, request payload, response status, and timing. Debug lines are hidden in normal runs; re-run the workflow with "[Enable debug logging](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/troubleshooting-workflows/enabling-debug-logging)" checked, or set the `ACTIONS_STEP_DEBUG` secret to `true`. The trace never prints the token: the authorization header is not part of the trace line, and a token stored as a repository secret is masked by the runner wherever it appears in output. For redacted private targets in multi-repo mode, the traced path collapses to `` and the payload is dropped entirely. Failures do not need debug mode; every error already carries the API's message and the fix, so reach for the trace when you need to see the requests that succeeded. +Every API call the action makes is traced as a debug line: method, path, request payload, response status, and timing. Debug lines are hidden in normal runs; re-run the workflow with "[Enable debug logging](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/troubleshooting-workflows/enabling-debug-logging)" checked, or set the `ACTIONS_STEP_DEBUG` secret to `true`. + +What the trace never shows: + +- **The token.** The authorization header is not part of the trace line, and a token stored as a repository secret is masked by the runner wherever it appears in output. +- **A redacted private target's detail.** For redacted private targets in multi-repo mode, the traced path collapses to `` and the payload is dropped entirely. + +Failures do not need debug mode; every error already carries the API's message and the fix, so reach for the trace when you need to see the requests that succeeded. ## Behavior does not match src/ (missing or stale bundle) -When you run the action from a ref (`uses: your-fork/github-settings-as-code@your-branch`), what executes is `lib/index.js`, the bundle, not the TypeScript under `src/`. The release tags (`vX.Y.Z`) and the moving major tag carry a freshly built bundle; branches do not - the bundle is not committed on main. So on a fork or working branch, build it yourself: run `bun run build:bundle` and commit the result on your branch (`lib/index.js` is gitignored, so stage it with `git add -f`). A ref without the bundle fails to start; a branch where you rebuilt `src/` without rebuilding behaves like the old code and no error tells you so. The rebuild is on you. +When you run the action from a ref (`uses: your-fork/github-settings-as-code@your-branch`), what executes is `lib/index.js`, the bundle, not the TypeScript under `src/`. The release tags (`vX.Y.Z`) and the moving major tag carry a freshly built bundle; branches do not, because the bundle is not committed on main. + +So on a fork or working branch, build it yourself: + +1. Run `bun run build:bundle`. +2. Commit the result on your branch. `lib/index.js` is gitignored, so stage it with `git add -f`. + +What goes wrong without that: + +- **No bundle on the ref:** the action fails to start. +- **A stale bundle:** a branch where you changed `src/` without rebuilding behaves like the old code, and no error tells you so. The rebuild is on you. From c5571d06eec2d2252dd04da0cdd95eadfdf9ac3e Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:28:17 -0400 Subject: [PATCH 2/2] docs(operate): name the literal result value in the section-error row The Exit behavior table's section-error row carried prose in its result cell where every other row carries the output value. The cell now reads `failed`, and the fact that the section itself is marked failed moved into the Outcome cell, so nothing is lost. --- docs/operate/check-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operate/check-mode.md b/docs/operate/check-mode.md index aeda1ca9e..54e5feeba 100644 --- a/docs/operate/check-mode.md +++ b/docs/operate/check-mode.md @@ -16,7 +16,7 @@ The step's exit code carries the verdict, and the `result` output names it: |---|---|---| | 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 | that section and the run are marked failed | 1 | +| 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)).