From 4fdf0a4955bf6cd6af053e7c9c3acf795b73c2d0 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:19:59 -0400 Subject: [PATCH] docs(reference): reshape the permissions, forward-compatibility, and secrets pages for skimming Every paragraph over the 70-word cap on the three pages becomes the device that shows it fastest: split paragraphs, bullets with bold lead-ins, a denial-policy verdict table, a variables-vs-secrets table, and a table of the three sibling secret stores. The grant list, the org caveats, the strict nested shapes, the operator-authored sources, and the environment-secret rules are now lists. "Three things worth knowing" now says four, matching its bullets. No fact was lost or added: the cross-model review listed the facts absent on each side of every page, and both lists are empty after two rounds. Headings are byte-identical, the generated regions are untouched, semantics.md is unchanged, and the probe reports 0 findings per page (was 4, 4, 8). --- docs/reference/forward-compatibility.md | 20 +++++++-- docs/reference/permissions.md | 38 +++++++++++----- docs/reference/secrets-and-vaults.md | 58 ++++++++++++++++++++----- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/reference/forward-compatibility.md b/docs/reference/forward-compatibility.md index 470f16866..cb63d7a2c 100644 --- a/docs/reference/forward-compatibility.md +++ b/docs/reference/forward-compatibility.md @@ -4,9 +4,13 @@ order: 160 # Forward compatibility -GitHub ships new settings fields faster than any wrapper can track them, so this action is passthrough-first by design: payloads are sent to the API verbatim except for documented normalizations (ref prefixes, topics splitting, vocabulary mapping), and new fields and rule types GitHub ships work the day they exist - declare them in `settings.yml`, no action update needed. This page draws the line: where the passthrough tenet holds, and where the surface is deliberately closed. +GitHub ships new settings fields faster than any wrapper can track them, so this action is passthrough-first by design. Payloads are sent to the API verbatim except for documented normalizations (ref prefixes, topics splitting, vocabulary mapping), and new fields and rule types GitHub ships work the day they exist: declare them in `settings.yml`, no action update needed. -The passthrough is the default: every section not named in the closed list below accepts fields it does not recognize and sends them through. The richest surfaces are `rulesets` (new rule types, bypass-actor fields, condition types), `repository`, `branches`, `environments`, `actions`, `pages`, and `code_scanning_default_setup`. The published JSON Schema follows the same tenet: it is documentation, not a gate, so unknown fields validate on purpose - declaring a field GitHub ships tomorrow must never read as an error. +This page draws the line: where the passthrough tenet holds, and where a shape is deliberately closed. + +The passthrough is the default: every section not named in the closed list below accepts fields it does not recognize and sends them through. The richest passthrough sections are `rulesets` (new rule types, bypass-actor fields, condition types), `repository`, `branches`, `environments`, `actions`, `pages`, and `code_scanning_default_setup`. + +The published JSON Schema follows the same tenet. It is documentation, not a gate, so unknown fields validate on purpose: declaring a field GitHub ships tomorrow must never read as an error. Two deliberate boundaries: @@ -15,8 +19,16 @@ Two deliberate boundaries: ## The closed sections -Nine sections are closed rather than passthrough: `collaborators`, `teams`, `workflows`, `custom_properties`, `secret_scanning_custom_patterns`, and the secret sections `actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, and `agents_secrets` reject entry keys they do not recognize. Their API calls carry at most the declared fields per entry (a `permission`, a property `value`, a sealed secret value, or a pattern's own fields; the workflow enable/disable calls carry none), so an extra key can only be a typo - and a misspelled `permission` would otherwise silently grant the default `push` role and never show up as drift, while a pattern's `state` and `push_protection_enabled` are read-only through the custom-pattern endpoints and would silently do nothing. +Nine sections are closed rather than passthrough: `collaborators`, `teams`, `workflows`, `custom_properties`, `secret_scanning_custom_patterns`, and the secret sections `actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, and `agents_secrets` reject entry keys they do not recognize. + +Their API calls carry at most the declared fields per entry (a `permission`, a property `value`, a sealed secret value, or a pattern's own fields; the workflow enable/disable calls carry none), so an extra key can only be a typo. Two typos a passthrough would have accepted silently: + +- **A misspelled `permission`** would grant the default `push` role and never show up as drift. +- **A pattern's `state` and `push_protection_enabled`** are read-only through the custom-pattern endpoints and would do nothing. + +A few nested shapes are strict for the same reason: -A few nested surfaces are strict for the same reason. Each key of the actions section's `cache` object is the entire body of its own endpoint, so an unrecognized cache key has nowhere to go and is rejected upfront; the rest of that section stays passthrough. Inside a declared environment, the `secrets` and `deployment_protection_rules` entries are strict the same way: their write bodies are built from the named fields alone, so an extra entry key would silently do nothing. +- **The actions section's `cache` object.** Each key of it is the entire body of its own endpoint, so an unrecognized cache key has nowhere to go and is rejected upfront; the rest of that section stays passthrough. +- **The `secrets` and `deployment_protection_rules` entries inside a declared environment.** Their write bodies are built from the named fields alone, so an extra entry key would silently do nothing. The wrapped form of the list sections is strict the same way: its keys are this action's own vocabulary, never sent to GitHub, so any other wrapper key is rejected upfront as a typo. The [undeclared policy page](undeclared-policy.md) is the one place that lists them. diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md index 9ebc3168d..38240f250 100644 --- a/docs/reference/permissions.md +++ b/docs/reference/permissions.md @@ -8,7 +8,12 @@ Every API call runs on the token you pass, so the grants on that token decide wh ## What to grant -The PAT permission column in the [Sections table](sections.md) names the grant each section needs. Grant only the permissions for the sections your settings file declares, plus the two cross-cutting grants that belong to no section: Contents at read when the action must fetch a settings file it does not have checked out (remote multi-repo targets), and Issues at read and write only when a `private-report` issue channel (`issue` or `issue-on-failure`) is enabled (see [private repositories](../operate/private-repositories.md)). Contents at read also lets `branches` tell a missing branch from an unprotected one in check mode. Beyond those the action never needs more. In multi-repo mode the token needs the same permissions on every target repository. +The PAT permission column in the [Sections table](sections.md) names the grant each section needs. Grant only the permissions for the sections your settings file declares, plus the two cross-cutting grants that belong to no section: + +- **Contents at read** when the action must fetch a settings file it does not have checked out (remote multi-repo targets). It also lets `branches` tell a missing branch from an unprotected one in check mode. +- **Issues at read and write** only when a `private-report` issue channel (`issue` or `issue-on-failure`) is enabled (see [private repositories](../operate/private-repositories.md)). + +Beyond those the action never needs more. In multi-repo mode the token needs the same permissions on every target repository. To manage everything in one PAT, grant Administration, Issues, Environments, Actions, Secrets, Dependabot secrets, Codespaces secrets, Agent secrets, Checks, Pages, Variables, Agent variables, Webhooks, Custom properties, and Secret scanning alerts at write, plus Contents at read and (for org repos) the Members organization permission at read. @@ -20,12 +25,12 @@ The default `GITHUB_TOKEN` can never hold most of these grants (Administration i ## How a denial surfaces -A section whose token lacks its grant fails with an error naming the denied request and its HTTP status, plus advice starting "To fix, grant" that names the exact fine-grained permission. Three things worth knowing when a run fails on permissions: +A section whose token lacks its grant fails with an error naming the denied request and its HTTP status, plus advice starting "To fix, grant" that names the exact fine-grained permission. Four things worth knowing when a run fails on permissions: -- `mode: check` changes no settings, so the read half of each permission is enough for a drift-report-only workflow, with the exceptions listed below. -- Fine-grained tokens surface a missing Administration permission as a 404, not a 403, on admin endpoints. The action treats both as permission errors and its messages name the exact permission to grant. -- GitHub returns a ruleset's `bypass_actors` only to a token with write access to the ruleset: a token without Administration at write reads the ruleset fine but never sees that field, so check mode leaves a declared `bypass_actors` out of the comparison and says so in a notice instead of reporting drift. -- `repos: "*"` discovery needs a user PAT; the workflow `GITHUB_TOKEN` and GitHub App installation tokens cannot enumerate a user's repositories. Remote multi-repo targets also need Contents: read on every target, because each repository's own settings.yml is fetched through the contents API. +- **Check mode needs the read half.** `mode: check` changes no settings, so the read half of each permission is enough for a drift-report-only workflow, with the exceptions listed below. +- **A 404 can be a denial.** Fine-grained tokens surface a missing Administration permission as a 404, not a 403, on admin endpoints. The action treats both as permission errors and its messages name the exact permission to grant. +- **`bypass_actors` is hidden from read-only tokens.** GitHub returns a ruleset's `bypass_actors` only to a token with write access to the ruleset: a token without Administration at write reads the ruleset fine but never sees that field, so check mode leaves a declared `bypass_actors` out of the comparison and says so in a notice instead of reporting drift. +- **Discovery and remote targets need more.** `repos: "*"` discovery needs a user PAT; the workflow `GITHUB_TOKEN` and GitHub App installation tokens cannot enumerate a user's repositories. Remote multi-repo targets also need Contents: read on every target, because each repository's own settings.yml is fetched through the contents API. The exceptions to check mode's read-only rule: @@ -40,14 +45,25 @@ Not every 403 is a missing grant. Rate limits can arrive as 403, and the action ## The denial policy -Permission failures are the only errors the run can be told to tolerate; everything else always fails with the API message verbatim (see [Semantics](semantics.md)). Two inputs set the policy. +Permission failures are the only errors the run can be told to tolerate; everything else always fails with the API message verbatim (see [Semantics](semantics.md)). Two inputs set the policy: + +| Input | A denied section is | The run | +| --- | --- | --- | +| `on-missing-permission: fail` (the default) | failed | fails | +| `on-missing-permission: warn` | skipped with a warning annotation | continues; when nothing else drifts or fails the result is `partial` and the run stays green | +| `required-sections` naming the section, even under `warn` | failed | fails regardless | -Under the default `on-missing-permission: fail`, any denied section fails the run. In apply mode the [preflight barrier](semantics.md#the-preflight-barrier) probes every active section (each declared section the `sections` input selects; all of them when that input is unset) read-only before anything is written, so a read denial under `fail` stops the run before the first write. The probe cannot see the write half of a grant, though: a token that reads a section but cannot write it still fails mid-apply. The engine is idempotent, so fixing the token and re-running converges. +Under `fail`, an apply run stops before its first write when a read is denied: the [preflight barrier](semantics.md#the-preflight-barrier) probes every active section (each declared section the `sections` input selects; all of them when that input is unset) read-only before anything is written. -`on-missing-permission: warn` turns a denial into a skip: the section is skipped with a warning annotation, the run continues, and when nothing else drifts or fails the result is `partial` and the run stays green. That is partial success as a policy, useful when one token manages a fleet whose repositories do not all grant the same permissions. +The probe cannot see the write half of a grant, though: a token that reads a section but cannot write it still fails mid-apply. The engine is idempotent, so fixing the token and re-running converges. -`required-sections` names the sections that must still fully apply even under `warn`: a denial on a required section fails the run regardless. It is a minimum-requirements floor - soften everything else, but never report success while, say, `rulesets` could not be applied. A required section must also be allowed by the `sections` input when that allowlist is set: an excluded section is never attempted, so requiring it would be a promise the run cannot check, and the pairing is rejected before any API call. +`warn` is partial success as a policy, useful when one token manages a fleet whose repositories do not all grant the same permissions. + +`required-sections` is a minimum-requirements floor: soften everything else, but never report success while, say, `rulesets` could not be applied. A required section must also be allowed by the `sections` input when that allowlist is set. An excluded section is never attempted, so requiring it would be a promise the run cannot check, and the pairing is rejected before any API call. ## Organization repositories -Two caveats live on the organization rather than the repository. The `teams` section needs the Members organization permission at read, and the token form only offers organization permissions once an organization is selected as the resource owner - add it by hand when you use the pre-filled form. And a `custom_properties` write can be refused upstream regardless of the grant: a 403 on the values PATCH can mean the org restricts a property's values to org actors (`values_editable_by: org_actors`), and a 422 means the property is not defined at the organization level. +Two caveats live on the organization rather than the repository: + +- **`teams` needs the Members organization permission at read.** The token form only offers organization permissions once an organization is selected as the resource owner, so add it by hand when you use the pre-filled form. +- **A `custom_properties` write can be refused upstream regardless of the grant.** A 403 on the values PATCH can mean the org restricts a property's values to org actors (`values_editable_by: org_actors`), and a 422 means the property is not defined at the organization level. diff --git a/docs/reference/secrets-and-vaults.md b/docs/reference/secrets-and-vaults.md index e44bc3140..64c3fa935 100644 --- a/docs/reference/secrets-and-vaults.md +++ b/docs/reference/secrets-and-vaults.md @@ -6,7 +6,7 @@ order: 170 Some settings are secrets. The first one this action manages is the webhook delivery secret (`webhooks[].config.secret`), and the problem it raises is general: `settings.yml` is a committed file, so a secret value can never be written into it, yet the API needs the real value at apply time. -The answer is a reference. A designated secret field takes a whole-value `$NAME` token, and the action resolves it from the environment of the step that runs it - the same place a workflow already keeps secrets. +The answer is a reference. A designated secret field takes a whole-value `$NAME` token, and the action resolves it from the environment of the step that runs it, the same place a workflow already keeps secrets. ```yaml settings webhooks: @@ -86,20 +86,34 @@ Here the vault step exports `WEBHOOK_SECRET` into the job environment, and the s ## What happens at run time -In apply mode, the action resolves every declared reference up front - after the read-only preflight, before the first write of any section. Resolution failures fail the repository cleanly with zero mutations: +In apply mode, the action resolves every declared reference up front, after the read-only preflight and before the first write of any section. Resolution failures fail the repository cleanly with zero mutations: - An UNSET variable fails, naming the reference and pointing at the step's `env` block. - A SET-BUT-EMPTY variable also fails. An empty vault lookup must not write an empty secret, so a failed lookup cannot pass silently. -Every resolved plaintext is registered with the runner's secret masker before it is used anywhere, on top of the protections the client always applies to secret-bearing requests: debug traces show `***` in place of the value, and the response body of a failed secret-carrying request is withheld wholesale (an error body can echo the rejected value). The value itself never appears in a drift line, a change line, a note, or a report. +Every resolved plaintext is registered with the runner's secret masker before it is used anywhere. That is on top of the protections the client always applies to secret-bearing requests: + +- **Debug traces** show `***` in place of the value. +- **The response body of a failed secret-carrying request** is withheld wholesale, since an error body can echo the rejected value. + +The value itself never appears in a drift line, a change line, a note, or a report. ## Check mode verifies syntax only -`mode: check` never reads the environment. References are validated for shape and policy (whole-value, not reserved, right provenance), so a typo'd literal still fails a check run - but the variable does not need to exist where checks run, and the secret's VALUE is never verified. For webhooks specifically, GitHub echoes a stored secret as `********`, so no mode can compare it; apply re-sends the declared secret on every run, which is also how a rotated value propagates. Check mode says this out loud as a "cannot verify" note. +`mode: check` never reads the environment. References are validated for shape and policy (whole-value, not reserved, right provenance), so a typo'd literal still fails a check run. The variable does not need to exist where checks run, and the secret's VALUE is never verified. + +For webhooks specifically, GitHub echoes a stored secret as `********`, so no mode can compare it. Apply re-sends the declared secret on every run, which is also how a rotated value propagates. Check mode says this out loud as a "cannot verify" note. ## Multi-repo: operator files only -References are honored only in settings sources the OPERATOR authors: the single-repo settings file, every `settings-file` layer of a `mode: render` step, `repos-dir` files, and the `defaults-file` document. A settings.yml fetched from a target repository (the `repos` input) is target-authored, and a reference there is a hard error: a target repository must not be able to route the operator's environment - and its secrets - into itself. Declare secret-bearing sections centrally when you manage a fleet. +References are honored only in settings sources the OPERATOR authors: + +- the single-repo settings file +- every `settings-file` layer of a `mode: render` step +- `repos-dir` files +- the `defaults-file` document + +A settings.yml fetched from a target repository (the `repos` input) is target-authored, and a reference there is a hard error: a target repository must not be able to route the operator's environment, and its secrets, into itself. Declare secret-bearing sections centrally when you manage a fleet. ## Repository Actions secrets @@ -113,13 +127,28 @@ actions_secrets: value: $PUBLISH_TOKEN ``` -GitHub never returns a secret's value, only names and timestamps, so check mode reconciles EXISTENCE: a declared-but-missing secret is drift, and the declared values get one cannot-verify note. Apply seals every declared value client-side against the repository's public key and re-writes it on every run, which is also how a rotated vault value propagates. Undeclared secrets are kept by default - a deleted secret's value is unrecoverable - and the wrapped `_undeclared: delete` form opts into deletion. +GitHub never returns a secret's value, only names and timestamps, so check mode reconciles EXISTENCE: a declared-but-missing secret is drift, and the declared values get one cannot-verify note. + +Apply seals every declared value client-side against the repository's public key and re-writes it on every run, which is also how a rotated vault value propagates. -Unlike the variables sections, a secret entry accepts ONLY `name` and `value` - an unknown key is rejected upfront rather than passed through. That is not an inconsistency: a variables entry's body goes to GitHub verbatim, so an extra key rides along and GitHub decides; a secret's PUT body is built from the sealed value alone, so an extra key has no destination and would "apply" successfully forever while doing nothing. +Undeclared secrets are kept by default, since a deleted secret's value is unrecoverable; the wrapped `_undeclared: delete` form opts into deletion. + +Unlike the variables sections, a secret entry accepts ONLY `name` and `value`: an unknown key is rejected upfront rather than passed through. That is not an inconsistency: + +| Entry | Where an extra key goes | Outcome | +| --- | --- | --- | +| a variables entry | the body goes to GitHub verbatim, so the key rides along | GitHub decides | +| a secret entry | the PUT body is built from the sealed value alone, so the key has no destination | the key would "apply" successfully forever while doing nothing | ## Dependabot, Codespaces, and Copilot agents secrets -Three sibling sections manage the other repository-level secret stores with the exact same shape and semantics: `dependabot_secrets` (private-registry credentials Dependabot uses when it resolves dependencies), `codespaces_secrets` (secrets exposed to development environments), and `agents_secrets` (the Copilot agents secret store). Each seals against its own public key and needs its own PAT permission - "Dependabot secrets", "Codespaces secrets", and "Agent secrets" respectively; note GitHub gates even the Codespaces secret reads at write access. +Three sibling sections manage the other repository-level secret stores with the exact same shape and semantics. Each seals against its own public key and needs its own PAT permission: + +| Section | Store | PAT permission | +| --- | --- | --- | +| `dependabot_secrets` | private-registry credentials Dependabot uses when it resolves dependencies | "Dependabot secrets" | +| `codespaces_secrets` | secrets exposed to development environments | "Codespaces secrets", and GitHub gates even the Codespaces secret reads at write access | +| `agents_secrets` | the Copilot agents secret store | "Agent secrets" | ```yaml settings dependabot_secrets: @@ -137,7 +166,7 @@ Everything said about `actions_secrets` applies: existence-only checks, one cann ## Environment secrets -Deployment environments carry their own secret store, managed as a nested `secrets` key on an `environments` entry - next to the `variables` key it mirrors: +Deployment environments carry their own secret store, managed as a nested `secrets` key on an `environments` entry, next to the `variables` key it mirrors: ```yaml settings environments: @@ -151,11 +180,18 @@ environments: value: $PROD_DEPLOY_TOKEN ``` -Each environment is its own sealing scope with its own public key, so the same secret name can carry a different value per environment, as above. The public key that seals a value is read at apply, by the first sealed write of its scope; check mode never requests it. Reconciliation runs after the environment itself is applied; in check mode against an environment that does not exist yet, the declared secrets cannot be listed, so a note says they are unverifiable until apply creates it. Within a declared `secrets` key, live secrets the entries do not declare are kept by default (their values are unrecoverable); the wrapped `{_undeclared: delete, entries}` form opts into deletion. The endpoints ride the same "Environments" PAT permission as the rest of the section. +Each environment is its own sealing scope with its own public key, so the same secret name can carry a different value per environment, as above. The rules of the nested store: + +- **The public key is read at apply,** by the first sealed write of its scope; check mode never requests it. +- **Reconciliation runs after the environment itself is applied.** In check mode against an environment that does not exist yet, the declared secrets cannot be listed, so a note says they are unverifiable until apply creates it. +- **Undeclared live secrets are kept by default.** Within a declared `secrets` key, live secrets the entries do not declare are kept (their values are unrecoverable); the wrapped `{_undeclared: delete, entries}` form opts into deletion. +- **The endpoints ride the same "Environments" PAT permission** as the rest of the section. ## Multi-repo fan-out -In multi-repo mode the defaults file is applied whole to every `repos` target that has no settings file of its own. A defaults file that declares a secret section (`actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, `agents_secrets`, or environment secrets) therefore writes those secrets into EVERY fileless target the run discovers. Sometimes that is exactly the point (a fleet-wide deploy key), and sometimes a surprise (a token fanned out to repositories that should never hold it). +In multi-repo mode the defaults file is applied whole to every `repos` target that has no settings file of its own. A defaults file that declares a secret section (`actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, `agents_secrets`, or environment secrets) therefore writes those secrets into EVERY fileless target the run discovers. + +Sometimes that is exactly the point (a fleet-wide deploy key), and sometimes a surprise (a token fanned out to repositories that should never hold it). Scope discovery deliberately before declaring secrets in a defaults file: