diff --git a/COVERAGE.md b/COVERAGE.md index 73328175..5073bc78 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -26,19 +26,19 @@ The tenet: this action can control everything about a repository and nothing abo | [Environments](https://docs.github.com/en/rest/deployments/environments) (wait_timer, reviewers, prevent_self_review, deployment_branch_policy protected_branches/custom_branch_policies flags), their [Actions variables](https://docs.github.com/en/rest/actions/variables), their [Actions secrets](https://docs.github.com/en/rest/actions/secrets), their [deployment branch policies](https://docs.github.com/en/rest/deployments/branch-policies) (custom patterns), and their [custom deployment protection rules](https://docs.github.com/en/rest/deployments/protection-rules) (GitHub App gates) | `environments` | PUT /repos/{owner}/{repo}/environments/{name} passthrough; check mode flattens GET's protection_rules[] back into the PUT shape. A declared per-environment `variables` key reconciles that environment's Actions variables AFTER the PUT, through GET/POST /repos/{owner}/{repo}/environments/{name}/variables and PATCH/DELETE .../variables/{name}: create missing, update divergent values, and delete undeclared ones by default (the wrapped `_undeclared: keep` form keeps them as notes); names match case-insensitively, values are plain text by design. A declared per-environment `secrets` key reconciles that environment's Actions secrets the same way the shipped secret sections do - GET .../environments/{name}/secrets (names + timestamps), GET .../secrets/public-key (issued at apply, inside the secret PUT, since that PUT may follow the environment's own creation; check mode never reads it), sealed PUT/DELETE .../secrets/{secret_name} - one sealing scope per environment, so same-named secrets in sibling environments resolve independently; undeclared secrets within a declared key are KEPT by default (values unrecoverable; `_undeclared: delete` opts in). A declared per-environment `deployment_branch_policies` key reconciles that environment's custom branch-policy patterns through GET/POST .../environments/{name}/deployment-branch-policies and DELETE .../deployment-branch-policies/{branch_policy_id}: create missing patterns, delete undeclared ones by default (`_undeclared: keep` softens to notes), and replace a matching pattern whose type differs (type is immutable upstream, so the change is delete + recreate; the upstream PUT is deliberately unused - its body is the name alone, and the name is the pattern's identity, so it can never help reconciliation). Declaring the key requires the singular `deployment_branch_policy` sibling with custom_branch_policies: true (rejected upfront otherwise, since the pattern writes would 404 only after the environment PUT landed), and its endpoints sit outside the Environments PAT permission: the list read needs Actions read, the writes need Administration write. A declared per-environment `deployment_protection_rules` key reconciles that environment's custom deployment protection rules (GitHub App gates) through GET/POST .../environments/{name}/deployment_protection_rules (the list documents NO pagination, so it is fetched in one call; the POST body is {integration_id}) and DELETE .../deployment_protection_rules/{protection_rule_id}: enable/disable ONLY, since GitHub offers no update call. Rules are declared by App slug and resolved to the integration id at apply time via ONE GET .../deployment_protection_rules/apps fetch (issued inside the enabling POST, only when a declared rule is missing, so check mode never reads it; a slug the listing does not carry is a hard error naming the available slugs). Undeclared rules within a declared key are KEPT by default - Apps can enable themselves as gates, and silently disabling a deployment gate is security-relevant - with `_undeclared: delete` opting into disabling; these endpoints also sit outside the Environments PAT permission (the enabled-rules list under Actions read, the Apps read and both writes under Administration). In check mode against a missing environment the declared variables, secrets, patterns, and protection rules cannot be listed, so notes say they are unverifiable until it exists; against a live environment whose custom_branch_policies flag is off, the patterns earn the same note while the flag drift comes from the environment diff. A declared per-environment `pinned` key reconciles the repository's pinned deployments sidebar over POST /graphql, AFTER every environment PUT - each mutation addresses the node_id the environment PUT/GET bodies already carry, so no extra lookup is made. The live pins read back through the repository's pinnedEnvironments connection (the EnvironmentPins query), where the ordering is the 1-based `position` field ON THE PinnedEnvironment NODE (it does not live on the Environment object) - and, verified against live GitHub, those numbers may be NON-CONTIGUOUS: unpinning leaves a hole, a new pin appends at the tail via a monotonic counter, and only a reorder renormalizes the list. Positions are therefore consumed as a sort key only, and reconciliation compares RANK ORDER: the entries declaring pinned: true must LEAD the pinned list in settings-file declaration order. pinEnvironment ({environmentId, pinned}) pins a missing pin (tail append) and unpins a pinned: false entry, reorderEnvironment ({environmentId, position}) pulls a divergent pin left into its declared rank; unpins are issued before pins, so a swap can never transiently exceed GitHub's cap of 10 pins (more than 10 declared pinned: true entries are rejected upfront, and a final count that would overflow the cap - live pins nobody declared count toward it - fails BEFORE the first pin mutation, naming the cap and the way to make room; check mode surfaces the same overflow as a note). Pins with no pinned declaration - undeclared environments, or entries without the key - are never unpinned; one sitting among the leading ranks is moved after the declared block, surfaced as a note in BOTH modes so check and apply agree exactly. Everything reads back, so check mode reports exact pin membership and order drift (the order line names both sequences). Undeclared environments are left untouched. Snapshot lists the environments (GET /repos/{owner}/{repo}/environments) and reads each one back as an entry: the PUT-shape settings off its GET body, and every nested list that has live items in its wrapped form under that list's default policy - variables with their values, secrets as `$SECRET_ENVIRONMENT__` references with a note each (the per-store minting the secret sections use, the environment folded into the store so same-named secrets in two environments get two variables; two names the fold collapses are noted once), branch-policy patterns while the custom_branch_policies flag is on, and the enabled protection rules by App slug (those two lists sit behind the Actions grant, so a denial there leaves the key out with a note and the rest of the environment reads back). The pin state reads back through the snapshot's own pins read (EnvironmentPinsSnapshot, the planner's selection under a declaration that tolerates no denial, so a concealed 404 is a PermissionDenied naming the grant, which the engine turns into a skipped section under on-missing-permission: warn and a failed run under fail, instead of reading as no pins): the pinned environments LEAD the file in rank order with `pinned: true`, since the planner reads declaration order as pin order, and the unpinned ones follow without the key (an absent key leaves a pin untouched); a pin naming no listed environment is a note. | | [Autolinks](https://docs.github.com/en/rest/repos/autolinks) | `autolinks` | GET/POST/DELETE /repos/{owner}/{repo}/autolinks; immutable upstream so changed entries are delete+recreate; undeclared autolinks DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. Snapshot reads the live autolinks back as entries under the delete default. | | [Actions permissions](https://docs.github.com/en/rest/actions/permissions) (enabled, allowed_actions + any base-permission field GitHub adds; selected_actions policy; workflow token default permissions + can_approve_pull_request_reviews; workflows access_level; artifact/log retention; cache limits; OIDC subject claim customization; fork PR contributor approval and private-repo fork PR workflow policies) | `actions` | Key routing (src/sections/actions/index.ts): the two known workflow-token keys -> PUT .../actions/permissions/workflow, selected_actions -> PUT .../permissions/selected-actions, access_level -> PUT .../permissions/access (private repositories only), artifact_and_log_retention -> PUT .../permissions/artifact-and-log-retention (body {days}, verbatim), cache.max_cache_retention_days -> PUT .../actions/cache/retention-limit, cache.max_cache_size_gb -> PUT .../actions/cache/storage-limit (each limit is its own single-field endpoint, so unrecognized cache keys are rejected; a 403 on the cache endpoints can mean an org- or enterprise-managed policy rather than a missing grant), oidc_customization_sub -> GET/PUT .../actions/oidc/customization/sub (201 on write; needs the "Actions" PAT permission instead of Administration, and include_claim_keys is compared positionally because claim-key order defines the subject format), fork_pr_contributor_approval -> GET/PUT .../permissions/fork-pr-contributor-approval (the approval_policy object, verbatim), fork_pr_workflows_private_repos -> GET/PUT .../permissions/fork-pr-workflows-private-repos (all four toggles are required by this action - GitHub does not document whether the PUT preserves or resets an omitted one, so the file declares the complete policy; the pair is documented for private repositories, so a denial on it can also mean the repository is public), EVERYTHING else -> base PUT .../actions/permissions verbatim. Whenever any base-permissions key is present in that PUT body, an undeclared `enabled` is defaulted to `true` (declaring a permissions field implies Actions are on), and selected_actions with no allowed_actions infers allowed_actions: selected. RISK: a key GitHub adds that belongs on a NEW sub-endpoint gets routed to the base PUT where GitHub ignores it; audit the routing whenever GitHub adds a permissions sub-endpoint. Snapshot reads every endpoint back onto its key (the allowlist only under the selected policy; the read-only fields each GET adds, like maximum_allowed_days, fall away); a sub-endpoint the token cannot read, such as the OIDC template under a token without the Actions permission, is noted and left out rather than failing the section. | -| [Actions secrets](https://docs.github.com/en/rest/actions/secrets) | `actions_secrets` | GET /repos/{owner}/{repo}/actions/secrets (names + timestamps), GET .../actions/secrets/public-key, PUT .../actions/secrets/{secret_name} (a sealed box in libsodium's crypto_box_seal format, {encrypted_value, key_id}), DELETE .../actions/secrets/{secret_name}. Values never live in settings.yml: each entry's `value` is a whole-value `$NAME` reference resolved from the step's env at apply time, sealed client-side, and re-written on EVERY apply so a rotated source value propagates. CAVEAT (existence-only): GitHub cannot return a value, so drift detection is presence, not content - check mode reports a declared-but-missing secret as drift and adds one cannot-verify note for the values; a changed value on GitHub's side is undetectable. Undeclared secrets are kept by default (their values are unrecoverable); the wrapped `_undeclared: delete` form opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references (so one name in two stores never shares a variable) with one note per value to export, since values are not readable. | -| [Dependabot secrets](https://docs.github.com/en/rest/dependabot/secrets) | `dependabot_secrets` | GET /repos/{owner}/{repo}/dependabot/secrets, GET .../dependabot/secrets/public-key, sealed PUT/DELETE .../dependabot/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Dependabot secret store (private-registry credentials Dependabot uses). Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | -| [Codespaces repository secrets](https://docs.github.com/en/rest/codespaces/repository-secrets) | `codespaces_secrets` | GET /repos/{owner}/{repo}/codespaces/secrets, GET .../codespaces/secrets/public-key, sealed PUT/DELETE .../codespaces/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Codespaces secret store (development environment secrets); the only repo-scoped Codespaces configuration surface. CAVEAT: GitHub's fine-grained "Codespaces secrets" permission gates even the reads at WRITE, so a read-only grant cannot run this section in check mode either. Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | -| [Copilot agents secrets](https://docs.github.com/en/rest/agents/secrets) | `agents_secrets` | GET /repos/{owner}/{repo}/agents/secrets, GET .../agents/secrets/public-key, sealed PUT/DELETE .../agents/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Copilot agents secret store. (GET .../agents/organization-secrets is a read-only view of org-inherited secrets, not a reconciliation target.) Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | +| [Actions secrets](https://docs.github.com/en/rest/actions/secrets) | `actions_secrets` | GET /repos/{owner}/{repo}/actions/secrets (names + timestamps), GET .../actions/secrets/public-key (read at apply, never in check mode), PUT .../actions/secrets/{secret_name} (a sealed box in libsodium's crypto_box_seal format, {encrypted_value, key_id}), DELETE .../actions/secrets/{secret_name}. Values never live in settings.yml: each entry's `value` is a whole-value `$NAME` reference resolved from the step's env at apply time, sealed client-side, and re-written on EVERY apply so a rotated source value propagates. CAVEAT (existence-only): GitHub cannot return a value, so drift detection is presence, not content - check mode reports a declared-but-missing secret as drift and adds one cannot-verify note for the values; a changed value on GitHub's side is undetectable. Undeclared secrets are kept by default (their values are unrecoverable); the wrapped `_undeclared: delete` form opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references (so one name in two stores never shares a variable) with one note per value to export, since values are not readable. | +| [Dependabot secrets](https://docs.github.com/en/rest/dependabot/secrets) | `dependabot_secrets` | GET /repos/{owner}/{repo}/dependabot/secrets, GET .../dependabot/secrets/public-key (read at apply), sealed PUT/DELETE .../dependabot/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Dependabot secret store (private-registry credentials Dependabot uses). Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | +| [Codespaces repository secrets](https://docs.github.com/en/rest/codespaces/repository-secrets) | `codespaces_secrets` | GET /repos/{owner}/{repo}/codespaces/secrets, GET .../codespaces/secrets/public-key (read at apply), sealed PUT/DELETE .../codespaces/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Codespaces secret store (development environment secrets); the only repo-scoped Codespaces configuration surface. CAVEAT: GitHub's fine-grained "Codespaces secrets" permission gates even the reads at WRITE, so a read-only grant cannot run this section in check mode either. Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | +| [Copilot agents secrets](https://docs.github.com/en/rest/agents/secrets) | `agents_secrets` | GET /repos/{owner}/{repo}/agents/secrets, GET .../agents/secrets/public-key (read at apply), sealed PUT/DELETE .../agents/secrets/{secret_name}. Same shape, sealing, and existence-only semantics as `actions_secrets`, over the Copilot agents secret store. (GET .../agents/organization-secrets is a read-only view of org-inherited secrets, not a reconciliation target.) Undeclared secrets kept by default; `_undeclared: delete` opts into deletion. Snapshot lists the secret names as per-store `$SECRET__` references with one note per value to export, since values are not readable. | | [Workflow enable/disable state](https://docs.github.com/en/rest/actions/workflows) | `workflows` | GET /repos/{owner}/{repo}/actions/workflows (paginated envelope), then PUT .../workflows/{id}/enable or /disable. Declared as {path, state: active or disabled}; a bare file name matches .github/workflows/; every live disabled_* state counts as disabled and a live "deleted" workflow counts as absent; undeclared workflows are never touched. Snapshot reads every present workflow back as {path, state}, each disabled_* state as disabled; deleted workflows are left out. | | [Check suite preferences](https://docs.github.com/en/rest/checks/suites) | `check_suite_preferences` | PATCH /repos/{owner}/{repo}/check-suites/preferences: per-app auto_trigger_checks toggles ({app_id, setting} pairs) controlling whether pushes automatically create check suites, sent verbatim. CAVEAT (write-only upstream): GitHub exposes NO companion GET, so check mode cannot verify the preferences - it emits one cannot-verify note and issues zero requests - and apply re-asserts the declared preferences on EVERY run (the PATCH's 200 echoes the resulting preferences, which the change line reads). With nothing to read, the apply-mode preflight cannot probe this section either, so a denied write surfaces only after other sections' writes landed (the Git LFS precedent). The token owner must be a repository administrator (fine-grained PATs with Checks read+write work). | | [GitHub Pages](https://docs.github.com/en/rest/pages/pages) (build_type, source, cname, https_enforced and any PUT field GitHub adds; pages: null disables the site) | `pages` | POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring branches' protection: null. In a mode: merge fold, pages: null over a lower layer's pages declaration removes the key from the merged document instead (the layering guide owns that rule); a multi-repo defaults-file never merges into a target's file. The Enterprise Cloud site-visibility boolean `public` (public vs repo-members-only) rides the PUT passthrough too - the dotcom docs omit it but the GHEC flavor documents it, and the GET echoes it in both, so declaring it applies and check-verifies today. Snapshot reads the live site back on the declared keys; a repository without Pages is omitted rather than declared null. | | [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. | | [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. | -| [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. | -| [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. A declared child team whose access comes through an undeclared parent's grant loses it with that revocation (the probe reads the inherited role as converged): declare the parent too, or keep. 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. | +| [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 left out with a note, 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. | +| [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. A declared child team whose access comes through an undeclared parent's grant loses it with that revocation (the probe reads the inherited role as converged): declare the parent too, or keep. 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; left out with a note: 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. | | [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. | -| [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. | +| [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 left out with a note, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. | | [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. | | [Copilot agents variables](https://docs.github.com/en/rest/agents/variables) | `agents_variables` | GET/POST /repos/{owner}/{repo}/agents/variables and PATCH/DELETE /repos/{owner}/{repo}/agents/variables/{name}: same upsert-by-uppercase-name semantics, exact check-mode diffing, and 30-item per_page cap as `actions_variables`, over the Copilot agents variable store. (GET .../agents/organization-variables is the read-only org-inherited view, out of scope.) Undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. CAVEAT: GitHub auto-migrated values from the older `copilot` Actions environment into this store, so a repository can hold agents variables nobody created here - run `mode: check` first (it lists them as deletion drift) before the first apply, or declare `_undeclared: keep`. Snapshot reads the variables back as name and value entries under the delete default. | | [Webhooks](https://docs.github.com/en/rest/repos/webhooks) | `webhooks` | GET/POST /repos/{owner}/{repo}/hooks, PATCH/DELETE .../hooks/{hook_id}, and PATCH .../hooks/{hook_id}/config. At most ONE web hook per config.url (the natural key); two live hooks under one url fail loudly, and a changed url is a NEW hook (the old one turns _undeclared: kept and noted by default, deleted under `_undeclared: delete`). A legacy service hook (name other than "web") or a hook without a config.url is outside the section: plan neither matches nor deletes it, snapshot notes it. Config-field drift goes through the config sub-endpoint, which updates named fields without the general PATCH's whole-config replacement, so a live secret the file does not declare is never removed; events/active drift rides the general PATCH with no config key. config.secret is declared as a whole-value `$NAME` reference resolved from the step env at apply time ([secrets and vaults](docs/reference/secrets-and-vaults.md)); GitHub echoes a live secret as "********", so check mode verifies everything except the secret (a cannot-verify note) and apply re-sends the declared secret every run so rotations propagate. insecure_ssl compares number and string spellings as equal (GitHub stores strings); events compare as a set. Hook urls appear in drift and change lines on purpose: they are configuration, not credentials (the secret is the masked part). Snapshot reads the hooks back as entries under the keep default, a set secret as a `$SECRET_WEBHOOK_` reference with a note to export it. | diff --git a/docs/reference/secrets-and-vaults.md b/docs/reference/secrets-and-vaults.md index ef4c6938..6a946f3c 100644 --- a/docs/reference/secrets-and-vaults.md +++ b/docs/reference/secrets-and-vaults.md @@ -151,7 +151,7 @@ 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. 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 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. ## Multi-repo fan-out diff --git a/docs/reference/sections.md b/docs/reference/sections.md index b0deec38..d8180666 100644 --- a/docs/reference/sections.md +++ b/docs/reference/sections.md @@ -16,10 +16,10 @@ Every top-level key a settings file can declare, one row per section: the endpoi | `branches` | protected-branch listing + classic branch protection + required-signatures sub-endpoint + app-by-slug actor lookup + GraphQL BranchProtectionRules + BranchProtectionRulesSnapshot + BranchProtectionRepository + BranchProtectionActorUser + BranchProtectionActorTeam + CreateBranchProtectionRule + UpdateBranchProtectionRule + DeleteBranchProtectionRule | Administration: write | untouched | `protection: null` removes protection; the protection PUT drops `required_signatures`, so declare it on any branch already carrying it; `force_push_bypassers` (users, `org/team`, `app/slug`) and `required_deployments` ride the GraphQL rule mutation; wildcard entries (`release/*`) reconcile entirely through GraphQL with a fixed key set; add Contents: read so check mode can tell a missing branch from an unprotected one | | `autolinks` | autolinks CRUD | Administration: write | deleted (settable) | immutable upstream, so changed entries are replaced | | `actions` | actions permissions + selected-actions + workflow token + access level + artifact/log retention + cache limits + OIDC subject claim + fork PR policies | Administration: write; the `oidc_customization_sub` key alone instead needs Actions: write | untouched | keys with their own sub-endpoint route there; everything else rides the base permissions PUT verbatim | -| `actions_secrets` | actions secrets list + public-key + sealed PUT + delete | Secrets: write | kept (settable) | `{name, value: $NAME}` sealed writes, re-sent every apply; existence-only checks, values unrecoverable | -| `dependabot_secrets` | dependabot secrets list + public-key + sealed PUT + delete | Dependabot secrets: write | kept (settable) | as `actions_secrets`, over the Dependabot secret store | -| `codespaces_secrets` | codespaces secrets list + public-key + sealed PUT + delete | Codespaces secrets: write | kept (settable) | as `actions_secrets`, over the Codespaces secret store | -| `agents_secrets` | agents secrets list + public-key + sealed PUT + delete | Agent secrets: write | kept (settable) | as `actions_secrets`, over the Copilot agents secret store | +| `actions_secrets` | actions secrets list + public-key (read at apply) + sealed PUT + delete | Secrets: write | kept (settable) | `{name, value: $NAME}` sealed writes, re-sent every apply; existence-only checks, values unrecoverable | +| `dependabot_secrets` | dependabot secrets list + public-key (read at apply) + sealed PUT + delete | Dependabot secrets: write | kept (settable) | as `actions_secrets`, over the Dependabot secret store | +| `codespaces_secrets` | codespaces secrets list + public-key (read at apply) + sealed PUT + delete | Codespaces secrets: write | kept (settable) | as `actions_secrets`, over the Codespaces secret store | +| `agents_secrets` | agents secrets list + public-key (read at apply) + sealed PUT + delete | Agent secrets: write | kept (settable) | as `actions_secrets`, over the Copilot agents secret store | | `workflows` | Actions workflows list, enable/disable | Actions: write | untouched | `{path, state: active or disabled}`; bare file names match `.github/workflows/` | | `check_suite_preferences` | check-suites preferences PATCH (no read endpoint exists upstream) | Checks: write | untouched | per-app `auto_trigger_checks` toggles; write-only: check mode cannot verify them (one note, zero requests) and apply re-asserts them every run; the token owner must be a repository administrator | | `pages` | POST/PUT/DELETE pages | Pages: write | untouched | `build_type: workflow` or `legacy` + source, `cname`, `https_enforced`, `public` (GHEC site visibility); `pages: null` disables the site | diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 84bd1936..68feb472 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -20,7 +20,7 @@ Twenty breaks (the ninth is for library consumers). One is silent (the fallback) | One redacted label in every mode | A single-repository run labelled its hidden target `private repository`; a fleet numbered them `private repository #N` | `private repository #N` everywhere; a run over one repository is `#1` | No error. A log filter or artifact-report reader matching `private repository:` exactly no longer matches; [section 10](#10-one-redacted-label-in-every-mode) | | Two live items under one identity fail the section | The last one listed won silently in most sections | Every list section refuses, naming the pair | The section fails until one is deleted on GitHub; [section 11](#11-two-live-items-under-one-identity-fail-the-section) | | The webhook snapshot placeholder | `$WEBHOOK_SECRET_` | `$SECRET_WEBHOOK_` | No error: an old reference keeps resolving from its old export; a new snapshot writes the new name, so move both together; [section 12](#12-the-webhook-snapshot-placeholder-leads-with-secret_) | -| One wording per concept in drift lines and notes | Per-section spellings of "cannot verify", "left out", and field drift; quoted webhook labels | One template each on the converted sites | Only a grep over the output notices; [section 13](#13-one-wording-per-concept-in-drift-lines-and-notes) | +| One wording per concept in drift lines and notes | Per-section spellings of "cannot verify", "left out", and field drift; quoted webhook labels | One template each | Only a grep over the output notices; [section 13](#13-one-wording-per-concept-in-drift-lines-and-notes) | | Webhooks manage web hooks only | A service hook was matched and deleted like any other | A service hook or url-less hook is outside the section | It is left alone and noted by snapshot; [section 14](#14-webhooks-manage-web-hooks-only) | | A ruleset without `source_type` is repository-owned | Kept with a note under `_undeclared: delete` | Deleted like any other undeclared repository ruleset | [section 15](#15-a-ruleset-without-source_type-is-repository-owned) | | Underscore keys are directives, never notes | An unknown `_note: ...` at the top level was dropped silently | Only `_layering` and `_undeclared` exist; any other underscore key fails validation, on a wrapper and at the top level alike | Validation fails before any section runs, naming the two directives; [section 16](#16-underscore-keys-are-directives-never-notes) | @@ -218,12 +218,10 @@ Every snapshot secret now leads with `SECRET_`, the store name second. A file ho ## 13. One wording per concept in drift lines and notes -Anything that greps the check output for these lines needs the new spelling. Two templates reach only the sites this release converts: +Anything that greps the check output for these lines needs the new spelling. Two templates reach every site: -- the cannot-verify line: the webhook secret and the write-only `check_suite_preferences` note; -- the left-out line: rulesets, webhooks, and the secondary snapshot reads (actions, environments, repository) a denied grant skips under `on-missing-permission: warn`; a denied primary read still says `skipped`. - -The other cannot-verify and omission notes (interaction_limits, teams, collaborators) keep their v2 line until a later release. +- the cannot-verify line: the webhook secret, the write-only `check_suite_preferences` note, `interaction_limits.expiry`, and the repository toggles GitHub cannot read back (`enable_git_lfs`); +- the left-out line: rulesets, webhooks, the teams and collaborators snapshot notes, an inherited interaction limit, and the secondary snapshot reads (actions, environments, repository) a denied grant skips under `on-missing-permission: warn`; a denied primary read still says `skipped`. | Line | v2 | v3 | |---|---|---| @@ -231,7 +229,9 @@ The other cannot-verify and omission notes (interaction_limits, teams, collabora | A field mismatch (milestones, rulesets, collaborators, teams, every list section) | `milestones[v1].state: "closed" != "open"` and `teams[platform]: live role "read" != declared "write"` | `milestones[v1].state: declared "closed" != live "open"; apply will set the declared value` | | A webhook's events | `... declared [...] != live [...] (compared order-insensitively)` | one line per element: `webhooks[].events: missing "release"` | | A value check mode cannot compare | `... so the declared value cannot be verified; apply re-sends it on every run so rotations propagate` | `