Skip to content

Commit dc7d044

Browse files
committed
fix(settings)!: unknown underscore keys fail everywhere; teams gets the _undeclared knob; GSAC_RETRY_BASE_MS
Four v3 rule decisions, each a small change: - Underscore rule: an unknown `_x` key is rejected at the document root as it already was inside a wrapper, with an error naming the two directives (`_layering`, `_undeclared`); a note belongs in a YAML comment. A misspelled `_layerin: replace` can no longer pass as a private note and merge a layer meant to replace. - teams takes `knobbed(TeamConfig)` with default `keep`: every plan lists the repository's teams, notes each undeclared direct grant, and revokes them under `_undeclared: delete` (DELETE /orgs/{org}/teams/{slug}/repos/{owner}/{repo}); access granted above the repository is noted, never touched. The snapshot writes the wrapper form. - RETRY_BASE_MS becomes GSAC_RETRY_BASE_MS, documented in the inputs reference; the old name is ignored, as an environment variable has no loud-error channel. - The 12 scenario files named with a section key's underscore are dashed, and a test over the corpus pins the rule. - Drift lines read declared-first everywhere: collaborators and teams said `live role X != declared Y`, the other sections `declared X != live Y`.
1 parent 798fc42 commit dc7d044

60 files changed

Lines changed: 551 additions & 124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/gen-action-docs.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ export const POLICY_ROW_PROSE: Record<UndeclaredPolicySection, PolicyRowProse> =
199199
caveat: "owner always exempt",
200200
override: "manage listed people without removing others",
201201
},
202+
teams: {
203+
caveat: "a grant made at the organization level is never touched",
204+
override: "make the file the complete inventory of direct team grants, revoking the rest",
205+
},
202206
actions_variables: { override: "declare the managed variables, tolerate the rest" },
203207
agents_variables: { override: "declare the managed variables, tolerate the rest" },
204208
rulesets: { override: "make the file the complete ruleset inventory" },

‎COVERAGE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The tenet: this action can control everything about a repository and nothing abo
3636
| [Code scanning default setup](https://docs.github.com/en/rest/code-scanning/code-scanning) | `code_scanning_default_setup` | GET/PATCH /repos/{owner}/{repo}/code-scanning/default-setup, PATCH body verbatim (state, query_suite, languages, runner_type, runner_label, threat_model). A 202 answer means GitHub rolls the change out in a configuration run, which the log names. Check compares declared keys only, languages as a set. Needs GitHub Advanced Security on private repositories; a 403 can mean that (or an archived repository) rather than a missing permission. Snapshot reads the GET body back on the declared keys; a null the slice does not take (a not-configured setup's runner_type) is omitted. |
3737
| [Code quality setup](https://docs.github.com/en/rest/code-quality/code-quality) | `code_quality_setup` | GET/PATCH /repos/{owner}/{repo}/code-quality/setup, PATCH body verbatim (state, languages, runner_type, runner_label, ai_findings_option), the near field-for-field mirror of code_scanning_default_setup. A 202 answer means GitHub rolls the change out in a configuration run, which the log names; a 409 means a configuration run is already in progress (re-run the workflow after it finishes); a 403 can mean code quality is unavailable on the repository or the repository is archived, rather than a missing permission, and a 422 (the change cannot be applied) carries GitHub's message verbatim. Check compares declared keys only, languages as a set. ASSUMPTION: the GET is graded as a plain read under the Administration permission, mirroring code scanning - the fine-grained grading of these endpoints is not verified against production. The sibling /code-quality/findings endpoints are read-only and stay out of scope. Snapshot reads the GET body back on the declared keys; a null the slice does not take (a not-configured setup's runner_type) is omitted. |
3838
| [Collaborators](https://docs.github.com/en/rest/collaborators/collaborators) (direct) | `collaborators` | PUT/DELETE /repos/{owner}/{repo}/collaborators/{username} (affiliation=direct); pending invitations reconciled alongside via GET /repos/{owner}/{repo}/invitations and PATCH/DELETE /repos/{owner}/{repo}/invitations/{invitation_id}: a declared user's matching pending invitation converges, a stale permission is PATCHed in place (a declared custom role cannot be verified against an invitation - the standard-roles enum - so the invitation is kept with a note), an expired invitation is cancelled and re-sent, and undeclared pending invitations follow the section's one undeclared policy (email invitations, which no username can declare, are noted and left untouched); vocabulary mapping push<->write, pull<->read for check mode; custom org role names pass through; undeclared direct collaborators REMOVED by default (kept as notes under the wrapped `_undeclared: keep` form), owner never touched; new users get invitations. Snapshot reads the direct collaborators and the pending username invitations back as entries under the delete default, each role through the inverse mapping (write -> push, read -> pull, custom role names verbatim); the repository owner, email invitations, and expired invitations are noted, not declared, and a live role no declaration plans as (a custom role named "push" or "pull") fails the collaborators section; the snapshot is written as partial without it. |
39-
| [Team repository permissions](https://docs.github.com/en/rest/teams/teams) (org repos) | `teams` | PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo}; probes GET /orgs/{owner} and no-ops with a note on personal accounts (404 only; 403/5xx still fail). Both modes probe each team's access with the v3.repository media type to read role_name, and the grant is issued only when the team lacks access or its live role diverges. Snapshot lists the teams with access (GET /repos/{owner}/{repo}/teams) and reads each team's role through the same probe, so a custom role reads back by name; noted, not declared: access granted at the organization or enterprise level, a probe 404 (no access, or a fine-grained token missing the grant; the note names both), a role the probe does not report, and a role no declaration plans as (a custom role named "push" or "pull"). A personal account snapshots nothing. |
39+
| [Team repository permissions](https://docs.github.com/en/rest/teams/teams) (org repos) | `teams` | PUT/DELETE /orgs/{org}/teams/{slug}/repos/{owner}/{repo}; probes GET /orgs/{owner} and no-ops with a note on personal accounts (404 only; 403/5xx still fail). Both modes probe each declared team's access with the v3.repository media type to read role_name, and the grant is issued only when the team lacks access or its live role diverges. The repository's team list (GET /repos/{owner}/{repo}/teams) names the undeclared teams: kept by default and noted (a team is often granted by the org for reasons outside one repository's file); under the wrapped `_undeclared: delete` form a team's DIRECT grant is revoked, while access granted at the organization or enterprise level is noted as beyond the repository's reach. Snapshot lists the same teams and reads each team's role through the same probe, so a custom role reads back by name, and writes the wrapped form with the keep default spelled out; noted, not declared: access granted at the organization or enterprise level, a probe 404 (no access, or a fine-grained token missing the grant; the note names both), a role the probe does not report, and a role no declaration plans as (a custom role named "push" or "pull"). A personal account snapshots nothing. |
4040
| [Milestones](https://docs.github.com/en/rest/issues/milestones) | `milestones` | POST/PATCH/DELETE /repos/{owner}/{repo}/milestones, matched by title, declared-keys-only (description/state untouched unless declared); undeclared milestones kept by default and surfaced as notes, because deleting a milestone DETACHES it from every issue carrying it - the wrapped `_undeclared: delete` form opts into exactly that. Snapshot reads every milestone, open and closed, back as entries under the keep default. |
4141
| [Interaction limits](https://docs.github.com/en/rest/interactions/repos) | `interaction_limits` | GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits, plus the two routed keys below. Limits self-expire (expiry tops out at six_months), so apply re-arms the declared limit on EVERY run and check mode reports drift once it lapses - schedule apply more often than the chosen expiry to keep it armed. The declared expiry is write-only (GitHub reads back only the computed expires_at), so check verifies the limit value, not the duration. `interaction_limits: null` clears a live repo-level limit - the base limit only, never the cap or bypass list below. In a mode: merge fold, interaction_limits: null over a lower layer's declaration removes the key from the merged document instead, mirroring pages: null. A 409 means an organization- or user-level limit overrides the repository's; writes surface that as a note, not a failure, while check mode still reports a mismatched declaration as drift - the org is the place to change it. `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm. Where the cap is unavailable the endpoints answer 405, which apply surfaces as a note (like the 409) and check as honest drift. `pull_request_creation_bypass` routes to GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits/pulls/bypass-list and reconciles: apply DELETEs only the undeclared logins and then PUTs only the missing ones ({users: [logins]}, compared case-insensitively; removals go first because the list holds at most 100 users, which also caps the declaration), never a wholesale replace; a declared empty list removes everyone. Snapshot reads back the repository-level limit (an inherited one is noted, not declared, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. |
4242
| [Actions variables](https://docs.github.com/en/rest/actions/variables) | `actions_variables` | GET/POST /repos/{owner}/{repo}/actions/variables and PATCH/DELETE /repos/{owner}/{repo}/actions/variables/{name}: plain-text repository variables, upserted by name. Names are case-insensitive (GitHub stores them uppercased regardless of how they are entered), so matching and duplicate rejection compare uppercased names. Values read back in full, which is what makes exact check-mode diffing possible; undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. The list endpoint caps per_page at 30 (not the usual 100), which the page loop honors so a large inventory is never truncated. Secrets are write-only material and deliberately NOT this section: they live in the `actions_secrets` section above. Snapshot reads the variables back as name and value entries under the delete default. |

‎action.yml‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,10 @@ inputs:
5252
document is written to (parent directories are created). The file holds
5353
exactly what apply would run: every section validated, each section that
5454
takes an undeclared policy in its policy-wrapper form with the policy
55-
made explicit, the other sections in their own shape, and private
56-
underscore keys and the _layering directives dropped. Feed it to a later
57-
apply or check step as its settings-file. Must not name one of the
58-
settings-file layers (the merge would overwrite it). Fails when set in
59-
apply or check.
55+
made explicit, the other sections in their own shape, and the _layering
56+
directives dropped. Feed it to a later apply or check step as its
57+
settings-file. Must not name one of the settings-file layers (the merge
58+
would overwrite it). Fails when set in apply or check.
6059
required: false
6160
default: ""
6261
snapshot-file:

‎docs/operate/layering.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ The step ends with `result: merged` and exit 0, or exit 1 with an error naming t
7070
| `_layering` | A list section's `{entries}` wrapper, or a file's top level | Merge time | Overrides `layering` for that section, or for every section of that file. Consumed by the merge: the merged file never carries it |
7171
| `_undeclared` | A list section's `{entries}` wrapper | Live state | What apply does to live resources the document does not declare: `keep` or `delete`. Travels through the merge and is resolved in the merged file. The [undeclared policy](../reference/undeclared-policy.md) page owns it |
7272

73-
The two underscore keys are this action's directives, never GitHub settings. Any other underscore key at a file's top level is a private note and is dropped from the merged file.
73+
The two underscore keys are this action's directives, never GitHub settings, and they are the whole underscore vocabulary: any other underscore key, at a file's top level or on a wrapper, fails validation with an error naming these two (a note belongs in a YAML comment). A misspelled directive can therefore never pass as a private note and quietly merge a layer meant to replace.
7474

7575
## The rules
7676

@@ -193,8 +193,8 @@ Reading it back:
193193

194194
The merged file is exactly what apply runs, so it is worth knowing its shape:
195195

196-
- Every list section that takes the `_undeclared` knob (the fifteen the [undeclared policy](../reference/undeclared-policy.md) lists) is in its `{_undeclared, entries}` wrapper form, with `_undeclared` resolved to an explicit `keep` or `delete`. Other lists (`branches`, `environments`, and the nested per-environment lists) stay as written.
197-
- No `_layering` anywhere, and no private underscore notes: both are consumed before the file is written.
196+
- Every list section that takes the `_undeclared` knob (the sixteen the [undeclared policy](../reference/undeclared-policy.md) lists) is in its `{_undeclared, entries}` wrapper form, with `_undeclared` resolved to an explicit `keep` or `delete`. Other lists (`branches`, `environments`, and the nested per-environment lists) stay as written.
197+
- No `_layering` anywhere: the directive is consumed before the file is written, and YAML comments do not survive the fold.
198198
- A `null` that met nothing below stays, keeping its engine meaning.
199199
- Every layer was validated on its own before the fold, and the result is validated again before it is written.
200200

@@ -256,6 +256,7 @@ The per-layer validation catches what a standalone settings file could not say,
256256
| A list section that is not a list or an `{entries}` wrapper (`labels: oops`) | Validation: `labels: Invalid input: expected a list of entries, or a mapping with "entries" (and an optional "_undeclared" policy), but this section parsed as string` |
257257
| A keyed entry without its key, here a label with no `name` (`labels: [{name: bug}, {color: d73a4a}]`) | Validation: `labels[1].name: Invalid input: expected string, received undefined` |
258258
| A non-mapping entry (`milestones: [v2]`) | Validation: `milestones[0]: Invalid input: expected object, received string` |
259+
| An underscore key that is not a directive on a wrapper (`labels: {_notes: x, entries: [{name: bug}]}`) | Validation: `labels: Unrecognized key: "_notes"; the wrapper's directives are "_undeclared" and, on a top-level section, "_layering", and nothing else - there are no private-note keys. Remove the key, or keep the note as a YAML comment` |
259260

260261
The fold itself refuses what only a merge can judge (`layer ".github/settings/repo.yml": ...`). A fold refusal names the key path (entries by index) and the kind of problem, never a value from the document: the merge runs without a repository's redaction context, so a label name or rule type echoed here could put a private repository's settings into a public log.
261262

@@ -285,7 +286,7 @@ A merge-mode log can therefore show your settings file's structure and, through
285286
| `_layering: merge` on a section with no layering key, on its wrapper or reached from the file level (`milestones: {_layering: merge, entries: [{title: v1}]}` or `{_layering: merge, milestones: [{title: v1}]}`) | `milestones has no layering key, so it cannot be layered by "merge"; declare _layering: replace or drop the directive` |
286287
| An unknown `_layering` value on a wrapper (`labels: {_layering: union, entries: [{name: bug}]}`) | `labels._layering must be "merge" or "replace"; got a string that is neither` |
287288
| An unknown `_layering` value at the file's top level (`_layering: union`) | `_layering must be "merge" or "replace"; got a string that is neither` |
288-
| A YAML anchor aliased inside its own node (`_notes: &loop {self: *loop}`) | `the document contains a reference cycle (a YAML anchor that includes itself); layers must be trees` |
289+
| A YAML anchor aliased inside its own node (`repository: &loop {self: *loop}`) | `the document contains a reference cycle (a YAML anchor that includes itself); layers must be trees` |
289290

290291
## Where to go next
291292

‎docs/operate/troubleshooting.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ What you see: the run fails during validation, naming the unknown keys and listi
5050

5151
What it means: a misspelled section that silently did nothing would break the loud-failure promise, so unknown top-level keys are hard errors (see [Forward compatibility](../reference/forward-compatibility.md)).
5252

53-
What to do: the message names all three options. Fix the typo. Or, for a deliberate private key, prefix it with an underscore, which the validator skips:
53+
What to do: the message names both options. Fix the typo, keeping any note as a YAML comment (an underscore key is not a private note: `_owner_notes: ...` fails with its own error naming the two directives the underscore is reserved for):
5454

5555
```yaml settings
56-
_owner_notes: contact the platform team before editing this file
56+
# owner notes: contact the platform team before editing this file
5757
labels:
5858
- name: bug
5959
color: d73a4a

‎docs/playbooks/incident-freeze.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ order: 390
77
During an incident, merges must stop in minutes and be restored just as fast, with both directions on the record. Two profile files and one dispatch workflow do it. The freeze:
88

99
```yaml settings
10-
# .github/profiles/freeze.yml
11-
_incident: INC-4471
10+
# .github/profiles/freeze.yml, incident INC-4471
1211

1312
rulesets:
1413
- name: incident freeze

‎docs/reference/inputs.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,15 @@ The discovery-only inputs apply to `repos: "*"`; the [multi-repo guide](../opera
4848
All three outputs are set on every run, whatever the mode and however it ended.
4949

5050
A `check` run exits 1 on any drift, so a downstream step usually reads `result` only when the step runs with `continue-on-error: true` or through `if: always()`. [Check mode](../operate/check-mode.md) has the exit-code table.
51+
52+
## Environment variables
53+
54+
Beside the inputs, a run reads these from its environment; the Actions runner sets the `GITHUB_*` ones, and the [command line](../start/cli.md) reads the same names.
55+
56+
| Variable | Read for |
57+
|---|---|
58+
| `GITHUB_TOKEN` | The token when the `token` input (or `--token`) is empty |
59+
| `GITHUB_REPOSITORY` | The default `repository`, the owner of a bare `<name>.yml` under `repos-dir`, and the one target never redacted |
60+
| `GITHUB_SERVER_URL`, `GITHUB_RUN_ID` | The run link a private report carries |
61+
| `GITHUB_API_URL` | The REST base URL (GitHub Enterprise Server) |
62+
| `GSAC_RETRY_BASE_MS` | A test knob: the real milliseconds in one plugin second, for the retry backoff, the rate-limit `Retry-After` waits, and the write limiter's gap. Unset, a second is a second. Set it only to make a retry scenario finish in milliseconds against a mock; against GitHub a shortened wait earns the next secondary rate limit |

0 commit comments

Comments
 (0)