From 2f6990e5083a2f25303d256a502355e9cf8ae1ed Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:51:25 -0400 Subject: [PATCH 1/7] feat(snapshot): repository, actions, and rulesets read back The three bespoke sections gain snapshot(): repository reads back the documented PATCH fields (an allowlist pinned to the octokit PATCH body type, since the passthrough slice cannot tell server fields apart; name and null fields are left out), topics, every readable toggle, and the two GraphQL keys, noting Git LFS, an owner-enforced toggle, and any denied sub-read; actions reads every endpoint back onto its key through the routing table (each routed destination now carries its own snapshot), with a denied sub-read a note naming the key and the allowlist read only under the selected policy; rulesets reads repository-owned rulesets back as keep entries, noting an inherited ruleset and an invisible bypass_actors instead of claiming an empty list. The round-trip harness fake now dispatches GraphQL onto the mock's GraphQL handlers, which the repository row (and the GraphQL-reading sections still to come) needs; the engine test's supported-section pins and the changed-sections fan-out canary follow the new snapshot() declarations and import edges. --- COVERAGE.md | 6 +- src/sections/actions/actions.docs.yml | 5 +- src/sections/actions/actions.test.ts | 151 +++++++++++++++ src/sections/actions/index.ts | 92 ++++++++- src/sections/repository/index.ts | 196 +++++++++++++++++++- src/sections/repository/repository.docs.yml | 10 +- src/sections/repository/repository.test.ts | 176 ++++++++++++++++++ src/sections/rulesets/index.ts | 85 +++++++-- src/sections/rulesets/rulesets.docs.yml | 6 +- src/sections/rulesets/rulesets.test.ts | 114 ++++++++++++ src/sections/shared/snapshot-helpers.ts | 1 - test/engine/snapshot.test.ts | 8 +- test/scripts/changed-sections.test.ts | 3 + test/sections/snapshot-rows/actions.ts | 50 +++++ test/sections/snapshot-rows/repository.ts | 57 ++++++ test/sections/snapshot-rows/rulesets.ts | 62 +++++++ 16 files changed, 989 insertions(+), 33 deletions(-) create mode 100644 test/sections/snapshot-rows/actions.ts create mode 100644 test/sections/snapshot-rows/repository.ts create mode 100644 test/sections/snapshot-rows/rulesets.ts diff --git a/COVERAGE.md b/COVERAGE.md index c3ee6142..8e3d7268 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -7,7 +7,7 @@ The tenet: this action can control everything about a repository and nothing abo | Area | Section | Notes | |---|---|---| -| [Repository core settings](https://docs.github.com/en/rest/repos/repos) (name, description, homepage, visibility/private, has_issues, has_wiki, has_projects, has_discussions, merge-strategy toggles, squash/merge commit title+message, delete_branch_on_merge, allow_update_branch, allow_auto_merge, web_commit_signoff_required, is_template, archived, default_branch) | `repository` | PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of declared keys against GET /repos. | +| [Repository core settings](https://docs.github.com/en/rest/repos/repos) (name, description, homepage, visibility/private, has_issues, has_wiki, has_projects, has_discussions, merge-strategy toggles, squash/merge commit title+message, delete_branch_on_merge, allow_update_branch, allow_auto_merge, web_commit_signoff_required, is_template, archived, default_branch) | `repository` | PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of declared keys against GET /repos. Snapshot reads back every documented PATCH field the GET reports except `name` (the repository's identity; a reused file would rename its target) and any null field, plus has_discussions; inside security_and_analysis only the sub-keys the PATCH accepts (dependabot_security_updates is enable_automated_security_fixes' own state); the GET's server fields (ids, urls, counts, has_downloads) are not PATCHable and fall away. Topics are read back when any are set, each readable toggle as GitHub reports it (an owner-enforced immutable-releases toggle is noted as immovable; when every toggle GET answers 404 the four are left out under a note, since a fine-grained token missing the grant is answered the same way), and the two GraphQL keys; Git LFS has no read and is noted, and a toggle or GraphQL read the token is denied is noted and left out. | | [security_and_analysis](https://docs.github.com/en/rest/repos/repos) (GitHub Advanced Security, secret scanning, push protection, and any nested toggle GitHub adds) | `repository` | Nested object inside the same PATCH /repos passthrough; flows through untouched. New sub-toggles GitHub adds (e.g. secret_scanning_ai_detection) work automatically. That includes the delegated-bypass surface: secret_scanning_delegated_bypass and secret_scanning_delegated_bypass_options (reviewers as {reviewer_id, reviewer_type, mode}) nest here per the OpenAPI spec, covering the "who can bypass push protection" UI including per-actor Exempt mode, and the GET echoes them so check mode verifies them. secret_scanning_validity_checks likewise nests here (the dotcom docs omit it, but the GHEC flavor documents it and GitHub's validity-checks page points at this endpoint). | | [Topics](https://docs.github.com/en/rest/repos/repos) | `repository (topics key)` | PUT /repos/{owner}/{repo}/topics. Only normalization: string-to-list splitting (normalizeTopics) and order-insensitive compare in check mode. | | [Dependabot alerts](https://docs.github.com/en/rest/repos/repos) (vulnerability alerts) | `repository (enable_vulnerability_alerts)` | PUT/DELETE /repos/{owner}/{repo}/vulnerability-alerts; check mode probes GET and treats 404 as disabled. GitHub couples the dependency graph to this endpoint: the PUT/DELETE also enable/disable the graph as a side effect (the graph toggle itself has no API of its own; see No public API). | @@ -19,13 +19,13 @@ The tenet: this action can control everything about a repository and nothing abo | [Issue creation policy](https://docs.github.com/en/graphql/reference/input-objects#updaterepositoryinput) (the Issues "Creation allowed by" control: everyone vs collaborators only) | `repository (issue_creation_policy)` | GraphQL only, sharing the Sponsor button's RepositoryFeatures query and updateRepository mutation: declared lowercase ("all"/"collaborators_only") and mapped to GitHub's ALL/COLLABORATORS_ONLY IssueCreationPolicy enum at the boundary. Live-probed before building: the REST PATCH ACCEPTS an issue_creation_policy field and silently ignores it (HTTP 200, no echo, no effect), and no REST GET carries it (the GET has pull_request_creation_policy but not the issue twin) - so GraphQL is the only read and write surface and the passthrough cannot absorb the key. A persistent two-value enum with full read-back, so check mode diffs it exactly. | | [Forking policy](https://docs.github.com/en/rest/repos/repos) | `repository (allow_forking, fork-related PATCH fields)` | allow_forking and any other fork-policy fields on the repo object ride the PATCH passthrough. (Org-side 'members can fork' policy is org-scoped, out of scope.) | | [Labels](https://docs.github.com/en/rest/issues/labels) | `labels` | Full CRUD on /repos/{owner}/{repo}/labels: upsert by name, rename via new_name (Probot parity), undeclared labels DELETED loudly by default; the wrapped `_undeclared: keep` form keeps them, each surfaced as a note. Snapshot reads the live labels back as entries under the delete default. | -| [Rulesets](https://docs.github.com/en/rest/repos/rules) (branch, tag, and push targets; all rule types, conditions, bypass_actors) | `rulesets` | GET/POST/PUT/DELETE /repos/{owner}/{repo}/rulesets: upsert-by-name, full-payload PUT, verbatim passthrough except ref-name prefixing (staging -> refs/heads/staging, ~DEFAULT_BRANCH passes through). New rule types/bypass fields/condition types GitHub ships work day one. Undeclared rulesets kept by default (notes only); the wrapped `_undeclared: delete` form deletes them. Org-sourced rulesets filtered out via source_type. GitHub returns bypass_actors only to a token with write access to the ruleset, so under a token without Administration write a declared bypass_actors is left out of the comparison and noted as not visible instead of reported as drift. | +| [Rulesets](https://docs.github.com/en/rest/repos/rules) (branch, tag, and push targets; all rule types, conditions, bypass_actors) | `rulesets` | GET/POST/PUT/DELETE /repos/{owner}/{repo}/rulesets: upsert-by-name, full-payload PUT, verbatim passthrough except ref-name prefixing (staging -> refs/heads/staging, ~DEFAULT_BRANCH passes through). New rule types/bypass fields/condition types GitHub ships work day one. Undeclared rulesets kept by default (notes only); the wrapped `_undeclared: delete` form deletes them. Org-sourced rulesets filtered out via source_type. GitHub returns bypass_actors only to a token with write access to the ruleset, so under a token without Administration write a declared bypass_actors is left out of the comparison and noted as not visible instead of reported as drift. Snapshot reads repository rulesets back as entries under the keep default (id, source, links, timestamps, and the caller's bypass state fall away); an inherited organization ruleset is noted, not declared, and a ruleset whose bypass_actors the token cannot see is left out with a note (kept undeclared), since an entry without the key would clear the bypass list on the next full-payload update. | | [Merge queue](https://docs.github.com/en/rest/repos/rules) | `rulesets` | Configured as the merge_queue rule type inside a branch ruleset; passes through verbatim like every other rule type. No dedicated endpoint exists; rulesets ARE the API for merge queue. | | [Tag protection (modern)](https://docs.github.com/en/rest/repos/rules) | `rulesets` | target: tag rulesets cover everything the retired legacy tag-protection API did (legacy API itself is out of scope, removed by GitHub). | | [Classic branch protection](https://docs.github.com/en/rest/branches/branch-protection) (literal branches, wildcard patterns, force-push bypass actors, required deployments) | `branches` | PUT /repos/{owner}/{repo}/branches/{branch}/protection passthrough; the four required keys are null-filled; protection: null issues DELETE (deleteBranchProtectionRule for a wildcard entry). Check mode flattens the GET shape ({enabled} wrappers, actor objects -> login/slug strings, *_url dropped) to compare like with like (src/sections/branches/index.ts flattenProtection). Every write is drift-gated: the PUT is planned when a declared key diverges or when the replacing PUT would remove a live setting the file omits, and a converged branch plans nothing. Three routed keys never ride the PUT: required_signatures (the PUT silently drops it) goes through its own POST/DELETE .../protection/required_signatures sub-endpoint when it drifts and again after any planned PUT (declared true POSTs, false DELETEs, undeclared untouched; a GET that omits the field reads as false; GitHub does not document whether the PUT preserves an existing requirement, so declare the toggle on any branch that carries one), while force_push_bypassers and required_deployments are REST-invisible entirely and ride ONE updateBranchProtectionRule GraphQL mutation on the same terms. force_push_bypassers is a list of actor strings - a bare login is a user, "org/team-slug" a team, "app/slug" a GitHub App - resolved to node ids when apply executes, ahead of the section's first write, so a misspelled actor fails before any write lands (check mode issues no lookup and reports the drift): users and teams through GraphQL (the BranchProtectionActorUser and BranchProtectionActorTeam lookups, new-format ids), Apps through the public GET /apps/{app_slug} REST lookup, whose node_id can still be the legacy format for old Apps (GitHub accepts it with a deprecation warning). required_deployments takes {environments: [...]}; declaring null turns the requirement off, an absent key leaves the live state untouched. CAVEAT (verified live): GitHub SILENTLY drops required-deployment environment names that do not exist while the mutation succeeds, so apply verifies the mutation's read-back and fails loudly naming any dropped name - the environments section runs before branches, so environments declared in the same file exist by then. WILDCARD entries (a name containing `*`, `?`, or `[`, e.g. release/*) are invisible to the REST endpoints (their docs point wildcard use at the GraphQL API), so they reconcile entirely through the createBranchProtectionRule/updateBranchProtectionRule/deleteBranchProtectionRule mutations (a create addresses the repository node id the BranchProtectionRepository query fetches) against the Repository.branchProtectionRules read; a wildcard protection accepts exactly the keys this section can round-trip through the GraphQL rule surface - enforce_admins (its isAdminEnforced twin live-verified bidirectionally against the REST view of the same rule), required_linear_history, allow_force_pushes, allow_deletions, block_creations, required_conversation_resolution, lock_branch, allow_fork_syncing, required_signatures, required_status_checks (strict, contexts), required_pull_request_reviews (required_approving_review_count, require_code_owner_reviews, dismiss_stale_reviews, require_last_push_approval), force_push_bypassers, required_deployments - and rejects anything else upfront (the actor-list controls have GraphQL fields but their REST vocabulary of database ids does not round-trip through node-id-based reads, so this section does not manage them on wildcard rules), pointing at rulesets as the recommended path for new configuration. The rules query fires only when an entry has a wildcard name or declares a GraphQL-routed key (a pure-REST declaration issues no GraphQL request), which also scopes the undeclared-rule NOTE: only a run whose declaration fires the query reports a live wildcard rule the file does not declare, as a note and never a deletion; wildcard updates have PATCH semantics (an omitted key keeps its live value, unlike the literal PUT's replace). Actor and environment names compare case-insensitively (GitHub canonicalizes them), and the routed lists reject duplicate names upfront. Snapshot lists the protected branches (GET /repos/{owner}/{repo}/branches?protected=true) and reads each one's classic protection back in the PUT vocabulary, controls that are off omitted and the required checks in both spellings the PUT takes, `contexts` and `checks` (an any-App check as app_id -1, the PUT's spelling of GitHub's null read-back, which the diff normalizes the same way); a listed branch whose protection read answers 404 (a ruleset protects it) is left out, and force_push_bypassers, required_deployments, and wildcard rules are not read back (GraphQL-only, noted once; a branch a wildcard rule protects comes back as a literal entry carrying that rule's protection, since no REST read names the pattern, and the note says to replace such entries with the wildcard entry). | | [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, 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 (made only when a declared rule is missing; 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 is GraphQL-only and not read back (noted once). | | [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. | +| [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. | diff --git a/src/sections/actions/actions.docs.yml b/src/sections/actions/actions.docs.yml index 4bb53000..951bf763 100644 --- a/src/sections/actions/actions.docs.yml +++ b/src/sections/actions/actions.docs.yml @@ -35,7 +35,10 @@ coverage: 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. + 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. schema: SettingsFile.actions: GitHub Actions permissions for the repository. ActionsConfig: GitHub Actions settings, routed to the right endpoint by key. diff --git a/src/sections/actions/actions.test.ts b/src/sections/actions/actions.test.ts index 42eeabce..97fa6527 100644 --- a/src/sections/actions/actions.test.ts +++ b/src/sections/actions/actions.test.ts @@ -7,6 +7,7 @@ import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js" import { REPO } from "../../../test/sections/section-run.js"; import { describeProblem } from "../../problem.js"; import { PermissionDenied } from "../contract/errors.js"; +import { sectionGrant } from "../contract/module.js"; import { grantFor } from "../contract/permissions.js"; import { actionsSection, endpointRouted } from "./index.js"; // The `as ActionsConfig` casts below simulate keys GitHub adds: the shape passes unknown keys through verbatim, which the static config type cannot @@ -547,6 +548,7 @@ describe("actions", () => { label: "actions.access", applied: "applied", body: (value) => ({ access_level: value }), + read: () => "none", }); expect(typeof paired.plan).toBe("function"); endpointRouted<"access_level", "Access">({ @@ -556,6 +558,7 @@ describe("actions", () => { label: "actions.access", applied: "applied", body: (value) => ({ access_level: value }), + read: () => "none", }); // @ts-expect-error an enum-valued key cannot be PUT bare: body is required endpointRouted<"access_level", "Access">({ @@ -563,6 +566,7 @@ describe("actions", () => { put: "putAccess", label: "actions.access", applied: "applied", + read: () => "none", }); }); @@ -576,3 +580,150 @@ describe("actions", () => { const _silent: Op = silent; }); }); + +describe("actions snapshot", () => { + const snapshot = (api: GithubClient) => + actionsSection.snapshot(planContext(actionsSection, api, REPO)); + /** The note a sub-read the fake has no body for produces: its 404 classifies as a denial. */ + const leftOut = (key: string, path: string, grant = sectionGrant(actionsSection)) => + `actions.${key}: left out of the snapshot - the token was denied GET ${path}: 404 Not Found (a 404 here can also mean the resource does not exist). To fix, ${grant}`; + + test("reads every endpoint back onto its key, dropping the server fields each GET adds", async () => { + const api = liveActions({ + [BASE]: { + enabled: true, + allowed_actions: "selected", + selected_actions_url: `https://api.github.com${BASE}/selected-actions`, + }, + [`${BASE}/selected-actions`]: { + github_owned_allowed: true, + verified_allowed: false, + patterns_allowed: ["actions/*"], + }, + [`${BASE}/workflow`]: { + default_workflow_permissions: "read", + can_approve_pull_request_reviews: false, + }, + [`${BASE}/access`]: { access_level: "organization" }, + [`${BASE}/artifact-and-log-retention`]: { days: 30, maximum_allowed_days: 400 }, + "/repos/o/r/actions/cache/retention-limit": { max_cache_retention_days: 5 }, + "/repos/o/r/actions/cache/storage-limit": { max_cache_size_gb: 20 }, + "/repos/o/r/actions/oidc/customization/sub": { + use_default: false, + include_claim_keys: ["repo", "context"], + }, + [`${BASE}/fork-pr-contributor-approval`]: { approval_policy: "first_time_contributors" }, + [`${BASE}/fork-pr-workflows-private-repos`]: { + run_workflows_from_fork_pull_requests: true, + send_write_tokens_to_workflows: false, + send_secrets_and_variables: false, + require_approval_for_fork_pr_workflows: true, + }, + }); + expect(await snapshot(api)).toEqual({ + value: { + enabled: true, + allowed_actions: "selected", + default_workflow_permissions: "read", + can_approve_pull_request_reviews: false, + selected_actions: { + github_owned_allowed: true, + verified_allowed: false, + patterns_allowed: ["actions/*"], + }, + access_level: "organization", + artifact_and_log_retention: { days: 30 }, + cache: { max_cache_retention_days: 5, max_cache_size_gb: 20 }, + oidc_customization_sub: { use_default: false, include_claim_keys: ["repo", "context"] }, + fork_pr_contributor_approval: { approval_policy: "first_time_contributors" }, + fork_pr_workflows_private_repos: { + run_workflows_from_fork_pull_requests: true, + send_write_tokens_to_workflows: false, + send_secrets_and_variables: false, + require_approval_for_fork_pr_workflows: true, + }, + }, + notes: [], + }); + expect(api.writes).toEqual([]); + }); + + test("a denied sub-read is a note naming its key with the read's own grant; the allowlist is skipped off the selected policy", async () => { + const live = liveActions({ + [BASE]: { enabled: true, allowed_actions: "all" }, + [`${BASE}/artifact-and-log-retention`]: { days: 90, maximum_allowed_days: 400 }, + "/repos/o/r/actions/cache/retention-limit": { max_cache_retention_days: 7 }, + "/repos/o/r/actions/cache/storage-limit": { max_cache_size_gb: 10 }, + [`${BASE}/fork-pr-contributor-approval`]: { approval_policy: "all_external_contributors" }, + }); + const requested: string[] = []; + const api: GithubClient = { + tryRequest: (method, path, payload) => { + requested.push(`${method} ${path}`); + return live.tryRequest(method, path, payload); + }, + tryGraphql: live.tryGraphql, + }; + const read = await snapshot(api); + expect(read).toEqual({ + value: { + enabled: true, + allowed_actions: "all", + artifact_and_log_retention: { days: 90 }, + cache: { max_cache_retention_days: 7, max_cache_size_gb: 10 }, + fork_pr_contributor_approval: { approval_policy: "all_external_contributors" }, + }, + notes: [ + leftOut( + "default_workflow_permissions/can_approve_pull_request_reviews", + `${BASE}/workflow`, + ), + leftOut("access_level", `${BASE}/access`), + leftOut( + "oidc_customization_sub", + "/repos/o/r/actions/oidc/customization/sub", + grantFor({ repo: ["actions"] }, undefined, "write"), + ), + `${leftOut("fork_pr_workflows_private_repos", `${BASE}/fork-pr-workflows-private-repos`)}. Note: the fork PR workflow settings are documented for private repositories, so a denial here can also mean the repository is public`, + ], + }); + // Off the "selected" policy there is no allowlist to read, so the GET is never issued. + expect(requested).not.toContain(`GET ${BASE}/selected-actions`); + expect(live.writes).toEqual([]); + }); + + test("a denied cache limit is noted by its own key while the readable sibling limit survives", async () => { + const api = liveActions({ + [BASE]: { enabled: true, allowed_actions: "all" }, + [`${BASE}/workflow`]: { default_workflow_permissions: "read" }, + [`${BASE}/access`]: { access_level: "none" }, + [`${BASE}/artifact-and-log-retention`]: { days: 90 }, + "/repos/o/r/actions/cache/retention-limit": { max_cache_retention_days: 7 }, + "/repos/o/r/actions/oidc/customization/sub": { use_default: true }, + [`${BASE}/fork-pr-contributor-approval`]: { approval_policy: "first_time_contributors" }, + [`${BASE}/fork-pr-workflows-private-repos`]: { + run_workflows_from_fork_pull_requests: false, + send_write_tokens_to_workflows: false, + send_secrets_and_variables: false, + require_approval_for_fork_pr_workflows: true, + }, + }); + const read = await snapshot(api); + expect(read.value?.cache).toEqual({ max_cache_retention_days: 7 }); + expect(read.notes).toEqual([ + leftOut("cache.max_cache_size_gb", "/repos/o/r/actions/cache/storage-limit"), + ]); + }); + + test("a denied primary read fails the section like any other, carrying the grant advice", async () => { + const api = liveActions({}); + let thrown: unknown; + try { + await snapshot(api); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(PermissionDenied); + expect((thrown as PermissionDenied).detail).toContain(sectionGrant(actionsSection)); + }); +}); diff --git a/src/sections/actions/index.ts b/src/sections/actions/index.ts index 180d65f8..d6c86217 100644 --- a/src/sections/actions/index.ts +++ b/src/sections/actions/index.ts @@ -12,6 +12,7 @@ import { plainData, type SectionPlan, } from "../contract/plan.js"; +import { projectOntoSchema, readOrNote } from "../shared/snapshot-helpers.js"; import { ActionsConfig } from "./schema.js"; const permission: SectionPermission = { repo: ["administration"] }; @@ -181,9 +182,12 @@ function sameClaimKeyOrder(declared: readonly string[], live: readonly string[]) // subsetDiff as passthrough. const LiveOidcSub = z.looseObject({ include_claim_keys: z.array(z.string()).nullish() }); +/** The base-permissions keys as the primary read reports them; the allowlist read hangs off the policy. */ +type BasePermissions = Pick; + /** - * The routing table holds the handler itself, so a routed key without a handler cannot exist. - * A function-valued property, not method shorthand, so the per-key value types check strictly. + * The routing table holds the handlers themselves, so a routed key without one cannot exist. + * Function-valued properties, not method shorthand, so the per-key value types check strictly. */ interface RoutedDestination { plan: ( @@ -192,6 +196,16 @@ interface RoutedDestination { declared: NonNullable, plan: ActionsPlan, ) => Promise; + /** + * Read the live state back as the settings file would declare it; undefined when none applies. + * A destination over several GETs notes a denied one itself, so its siblings survive. + */ + snapshot: ( + ctx: ActionsContext, + section: SectionMeta, + base: BasePermissions, + notes: string[], + ) => Promise; } /** `N` is inferred from the GET alone, so a PUT of another name does not compile. */ @@ -204,6 +218,8 @@ export function endpointRouted( /** The change line apply reports after the PUT lands. */ applied: string; describe?: string; + /** The GET body as the settings file declares the key (the inverse of `body`). */ + read: (live: unknown) => ActionsConfig[K]; } & (NonNullable extends Record ? { body?: (declared: NonNullable) => Record } : { body: (declared: NonNullable) => Record }), @@ -226,9 +242,16 @@ export function endpointRouted( }); } }, + snapshot: async (ctx) => wiring.read(await ctx.read[wiring.get].call()), }; } +/** The live body of one routed key projected onto its slice of the section shape. */ +function sliceOf(key: K): (live: unknown) => ActionsConfig[K] { + const slice: z.ZodType = ActionsConfig.shape[key]; + return (live) => projectOntoSchema(slice, live) as ActionsConfig[K]; +} + // Every DECLARED key names its destination, so the mapped `satisfies` makes a schema field with no // entry a compile error; a routed entry IS its handler, so routed-but-unhandled cannot exist either. // Undeclared (future) keys fall through to the base permissions PUT verbatim, never silently dropped. @@ -255,6 +278,15 @@ const KEY_DESTINATION = { }); } }, + // The allowlist exists only under the "selected" policy (the GET 409s otherwise), so no + // other policy reads it: an allowlist beside another policy is a document the shape rejects. + snapshot: async (ctx, _section, base) => { + if (base.allowed_actions !== "selected") { + return undefined; + } + const probe = await ctx.read.getSelected.probeAbsent(); + return "missing" in probe ? undefined : sliceOf("selected_actions")(probe.data); + }, }, default_workflow_permissions: "workflow", can_approve_pull_request_reviews: "workflow", @@ -264,6 +296,7 @@ const KEY_DESTINATION = { label: "actions.access", applied: "applied workflows access level", body: (value) => ({ access_level: value }), + read: (live) => sliceOf("access_level")((live as { access_level?: unknown }).access_level), }), artifact_and_log_retention: endpointRouted({ get: "getRetention", @@ -271,6 +304,7 @@ const KEY_DESTINATION = { label: "actions.artifact_and_log_retention", applied: "applied artifact and log retention", describe: "setting the artifact and log retention window", + read: sliceOf("artifact_and_log_retention"), }), cache: { plan: async (ctx, _section, declared, plan) => { @@ -293,6 +327,20 @@ const KEY_DESTINATION = { } } }, + // Each limit is its own GET, and a 403 on one can be an org-managed policy on that limit alone, + // so a denied limit is noted by key while the other still reads back. + snapshot: async (ctx, _section, _base, notes) => { + const limits: Record = {}; + for (const [key, wiring] of Object.entries(CACHE_ENDPOINT_BY_KEY)) { + const read = await readOrNote(notes, `actions.cache.${key}`, () => + ctx.read[wiring.get].call(), + ); + if (!("denied" in read)) { + Object.assign(limits, read.value); + } + } + return Object.keys(limits).length === 0 ? undefined : sliceOf("cache")(limits); + }, }, oidc_customization_sub: { plan: async (ctx, section, declared, plan) => { @@ -325,6 +373,10 @@ const KEY_DESTINATION = { }); } }, + snapshot: async (ctx, section) => + sliceOf("oidc_customization_sub")( + parseLive(section, ENDPOINTS.getOidcSub, LiveOidcSub, await ctx.read.getOidcSub.call()), + ), }, fork_pr_contributor_approval: endpointRouted({ get: "getForkPrApproval", @@ -332,6 +384,7 @@ const KEY_DESTINATION = { label: "actions.fork_pr_contributor_approval", applied: "applied the fork PR contributor approval policy", describe: "setting the fork PR contributor approval policy", + read: sliceOf("fork_pr_contributor_approval"), }), fork_pr_workflows_private_repos: endpointRouted({ get: "getForkPrPrivate", @@ -339,6 +392,7 @@ const KEY_DESTINATION = { label: "actions.fork_pr_workflows_private_repos", applied: "applied the private-repo fork PR workflow settings", describe: "setting the private-repo fork PR workflow settings", + read: sliceOf("fork_pr_workflows_private_repos"), }), } satisfies { [K in keyof ActionsConfig]-?: "base" | "workflow" | RoutedDestination }; @@ -369,6 +423,20 @@ async function planRouted( await ROUTED_DESTINATIONS[key].plan(ctx, section, declared, plan); } +/** Read one routed key back; generic so the handler and the value stay correlated to one key. */ +async function snapshotRouted( + key: K, + ctx: ActionsContext, + section: SectionMeta, + base: BasePermissions, + notes: string[], +): Promise { + const read = await readOrNote(notes, `actions.${key}`, () => + ROUTED_DESTINATIONS[key].snapshot(ctx, section, base, notes), + ); + return "denied" in read ? undefined : read.value; +} + function keysTo(destination: "base" | "workflow"): Set { return new Set( Object.entries(KEY_DESTINATION) @@ -460,4 +528,24 @@ export const actionsSection = { } return plan; }, + // The base permissions are the primary read, so their denial classifies the section; every + // other key the token cannot read is a note naming it. + async snapshot(ctx) { + const notes: string[] = []; + const base = projectOntoSchema(ActionsConfig, await ctx.read.getPermissions.call()); + const value: Record = { ...base }; + const workflow = await readOrNote(notes, `actions.${[...WORKFLOW_KEYS].join("/")}`, () => + ctx.read.getWorkflow.call(), + ); + if (!("denied" in workflow)) { + Object.assign(value, projectOntoSchema(ActionsConfig, workflow.value)); + } + for (const key of ROUTED_KEYS) { + const read = await snapshotRouted(key, ctx, this, base, notes); + if (read !== undefined) { + value[key] = read; + } + } + return { value: value as ActionsConfig, notes }; + }, } satisfies SectionModule<"actions", typeof ENDPOINTS>; diff --git a/src/sections/repository/index.ts b/src/sections/repository/index.ts index 3fb7857c..455d85ce 100644 --- a/src/sections/repository/index.ts +++ b/src/sections/repository/index.ts @@ -1,5 +1,7 @@ +import type { operations } from "@octokit/openapi-types"; import { z } from "zod"; import { phantomKeys, phantomNote, subsetDiff } from "../../engine/diff.js"; +import type { MustBeNever } from "../../types.js"; import { type EndpointDecl, repoVariables } from "../contract/endpoints.js"; import { type GraphqlOpDecl, type GraphqlVariablesOf, graphqlOp } from "../contract/graphql.js"; import { parseLive } from "../contract/live.js"; @@ -8,6 +10,7 @@ import { requirePlainMapping, type SectionMeta, type SectionModule, + sectionGrant, } from "../contract/module.js"; import type { SectionPermission } from "../contract/permissions.js"; import { @@ -17,6 +20,7 @@ import { plainData, type SectionPlan, } from "../contract/plan.js"; +import { readOrNote } from "../shared/snapshot-helpers.js"; import { RepositoryConfig } from "./schema.js"; export function normalizeTopics(raw: unknown): string[] { @@ -129,6 +133,97 @@ const ENDPOINTS = { // GitHub may return topics as null or omit them; the rest of the body rides into subsetDiff as passthrough. const LiveRepository = z.looseObject({ topics: z.array(z.string()).nullish() }); +/** The repo PATCH body as GitHub documents it: the fields the passthrough can send back. */ +type RepoPatchBody = NonNullable< + operations["repos/update"]["requestBody"] +>["content"]["application/json"]; + +/** PATCH fields the API accepts (verified live) that its OpenAPI descriptor omits, so the pin cannot see them. */ +type UndocumentedPatchField = "has_discussions"; + +/** + * The GET also reports what nobody can PATCH (ids, urls, counts, has_downloads) and the passthrough + * slice cannot tell those apart, so the PATCH fields are spelled out and pinned below. + * `name` stays out: a settings file reused on another repository would rename it. + */ +const SNAPSHOT_PATCH_FIELDS = [ + "description", + "homepage", + "private", + "visibility", + "security_and_analysis", + "has_issues", + "has_projects", + "has_wiki", + "has_discussions", + "has_pull_requests", + "pull_request_creation_policy", + "is_template", + "default_branch", + "allow_squash_merge", + "allow_merge_commit", + "allow_rebase_merge", + "allow_auto_merge", + "delete_branch_on_merge", + "allow_update_branch", + "use_squash_pr_title_as_default", + "squash_merge_commit_title", + "squash_merge_commit_message", + "merge_commit_title", + "merge_commit_message", + "archived", + "allow_forking", + "web_commit_signoff_required", +] as const satisfies readonly (keyof RepoPatchBody | UndocumentedPatchField)[]; + +/** A PATCH field octokit documents that the list above neither reads back nor leaves out by name. */ +type _SnapshotPatchFieldsComplete = MustBeNever< + Exclude +>; + +/** The nested security_and_analysis object as the PATCH body accepts it. */ +type SecurityAndAnalysisPatch = NonNullable; + +/** A nested key the API accepts (see the coverage notes) that the descriptor's PATCH body omits. */ +type UndocumentedSecurityField = "secret_scanning_validity_checks"; + +/** + * The GET's dependabot_security_updates is the automated-security-fixes toggle, already emitted from + * its own endpoint, and the PATCH rejects an unknown sub-key, so the nested object is pinned too. + */ +const SECURITY_AND_ANALYSIS_PATCH_FIELDS = [ + "advanced_security", + "code_security", + "secret_scanning", + "secret_scanning_push_protection", + "secret_scanning_ai_detection", + "secret_scanning_non_provider_patterns", + "secret_scanning_delegated_alert_dismissal", + "secret_scanning_delegated_bypass", + "secret_scanning_delegated_bypass_options", + "secret_scanning_validity_checks", +] as const satisfies readonly (keyof SecurityAndAnalysisPatch | UndocumentedSecurityField)[]; + +/** A nested PATCH sub-key octokit documents that the list above does not read back. */ +type _SecurityAndAnalysisFieldsComplete = MustBeNever< + Exclude +>; + +/** The live security_and_analysis object narrowed to its PATCHable sub-keys; undefined when none. */ +function snapshotSecurityAndAnalysis(live: unknown): Record | undefined { + if (typeof live !== "object" || live === null || Array.isArray(live)) { + return undefined; + } + const out: Record = {}; + for (const field of SECURITY_AND_ANALYSIS_PATCH_FIELDS) { + const value = (live as Record)[field]; + if (value !== undefined && value !== null) { + out[field] = value; + } + } + return Object.keys(out).length === 0 ? undefined : out; +} + const FEATURES_QUERY = graphqlOp<{ owner: string; repo: string }>()({ name: "RepositoryFeatures", kind: "read", @@ -337,6 +432,12 @@ const GRAPHQL_ROUTED_KEYS = [ }, ] as const satisfies readonly RoutedKey[]; +/** Why a routed field's live value cannot be read into the settings vocabulary. */ +function unreadableRoutedValue(entry: RoutedKey, raw: unknown, opName: string): string { + const hint = entry.unreadableHint ? `; ${entry.unreadableHint}` : ""; + return `GRAPHQL ${opName} returned ${entry.field} ${JSON.stringify(raw)}, which this section cannot read as a repository.${entry.key} value${hint}`; +} + /** * Strictness is scoped to the DECLARED keys: an unreadable value (the SDL-nullable policy, a future * enum member) must fail loudly for a key the file declares and must not fail a run that never declared it. @@ -350,9 +451,8 @@ function decodeRoutedFields( for (const entry of routed) { const decoded = entry.decode(fields[entry.field]); if (decoded === undefined) { - const hint = entry.unreadableHint ? `; ${entry.unreadableHint}` : ""; throw new Error( - `repository: GRAPHQL ${opName} returned ${entry.field} ${JSON.stringify(fields[entry.field])}, which this section cannot read as a repository.${entry.key} value${hint}. Drop the key, or update the action if GitHub's vocabulary moved`, + `repository: ${unreadableRoutedValue(entry, fields[entry.field], opName)}. Drop the key, or update the action if GitHub's vocabulary moved`, ); } values[entry.key] = decoded; @@ -365,17 +465,22 @@ interface LiveRoutedState { values: Record; } -async function fetchRoutedState( - ctx: RepositoryContext, - routed: readonly RoutedKey[], -): Promise { - const data = await ctx.read.featuresQuery.call(repoVariables(ctx)); +/** The Repository object of a features read, with the node id the mutation addresses. */ +function repositoryNode(data: Record): Record & { id: string } { const repository = (data as { repository?: Record }).repository; if (!repository || typeof repository.id !== "string") { throw new Error( `repository: GRAPHQL ${FEATURES_QUERY.name} returned no repository object with an id, so the ${GRAPHQL_ROUTED_KEYS.map((entry) => entry.key).join("/")} state cannot be read. Check the token's repository access`, ); } + return repository as Record & { id: string }; +} + +async function fetchRoutedState( + ctx: RepositoryContext, + routed: readonly RoutedKey[], +): Promise { + const repository = repositoryNode(await ctx.read.featuresQuery.call(repoVariables(ctx))); return { id: repository.id, values: decodeRoutedFields(repository, routed, FEATURES_QUERY.name) }; } @@ -553,4 +658,81 @@ export const repositorySection = { } return plan; }, + // A null PATCH field is GitHub's "unset", so it is left out rather than declared as null. + async snapshot(ctx) { + const notes: string[] = []; + const live = parseLive(this, ENDPOINTS.get, LiveRepository, await ctx.read.get.call()); + const value: Record = {}; + for (const field of SNAPSHOT_PATCH_FIELDS) { + const read = + field === "security_and_analysis" ? snapshotSecurityAndAnalysis(live[field]) : live[field]; + if (read !== undefined && read !== null) { + value[field] = read; + } + } + if (live.topics !== undefined && live.topics !== null && live.topics.length > 0) { + value.topics = [...live.topics]; + } + const probes: Array<{ + toggle: ReadableToggle; + live: LiveToggle | undefined; + concealable: boolean; + }> = []; + for (const toggle of READABLE_TOGGLES) { + const read = await readOrNote(notes, `repository.${toggle.key}`, async () => { + const answer = await ctx.read[toggle.get].tryCall(); + if ("error" in answer) { + // The declared 422 ("not applicable") is answered only to a granted token. + return { live: undefined, concealable: answer.error.status === 404 }; + } + const live = parseLive(this, ENDPOINTS[toggle.get], toggle.live, answer.data); + return { live, concealable: false }; + }); + if (!("denied" in read)) { + probes.push({ toggle, ...read.value }); + } + } + // The toggle GETs share one grant and answer 404 for "off", the same 404 a fine-grained token + // missing the grant is concealed behind. One other answer proves the grant; all 404s prove + // nothing, so the toggles are left out rather than written as off. + if (probes.length > 0 && probes.every((probe) => probe.concealable)) { + notes.push( + `repository.${probes.map((probe) => probe.toggle.key).join("/")}: every toggle GET answered 404, which reads as off but is also how a fine-grained token missing the grant is answered, so they are left out; if the token does ${sectionGrant(this)}, they are all off and can be declared false`, + ); + } else { + for (const { toggle, live } of probes) { + const enabled = live === undefined ? false : toggle.isEnabled(live); + value[toggle.key] = enabled; + if (live !== undefined && toggle.isEnforced?.(live) === true) { + notes.push( + `repository.${toggle.key}: ${OWNER_ENFORCED}, so it reads back as ${enabled} but cannot be changed from the repository`, + ); + } + } + } + for (const toggle of WRITE_ONLY_TOGGLES) { + notes.push( + `repository.${toggle.key}: GitHub exposes no endpoint to read ${toggle.label} back, so the snapshot leaves it out; declare it yourself to manage it`, + ); + } + const routed = await readOrNote( + notes, + GRAPHQL_ROUTED_KEYS.map((entry) => `repository.${entry.key}`).join(" and "), + () => ctx.read.featuresQuery.call(repoVariables(ctx)), + ); + if (!("denied" in routed)) { + const repository = repositoryNode(routed.value); + for (const entry of GRAPHQL_ROUTED_KEYS) { + const decoded = entry.decode(repository[entry.field]); + if (decoded === undefined) { + notes.push( + `repository.${entry.key}: ${unreadableRoutedValue(entry, repository[entry.field], FEATURES_QUERY.name)}, so the snapshot leaves it out`, + ); + continue; + } + value[entry.key] = decoded; + } + } + return { value: value as RepositoryConfig, notes }; + }, } satisfies SectionModule<"repository", typeof ENDPOINTS, typeof GRAPHQL_OPS>; diff --git a/src/sections/repository/repository.docs.yml b/src/sections/repository/repository.docs.yml index 2126e743..5466d6b0 100644 --- a/src/sections/repository/repository.docs.yml +++ b/src/sections/repository/repository.docs.yml @@ -18,7 +18,15 @@ coverage: PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of - declared keys against GET /repos. + declared keys against GET /repos. Snapshot reads back every documented PATCH field the GET + reports except `name` (the repository's identity; a reused file would rename its target) and + any null field, plus has_discussions; inside security_and_analysis only the sub-keys the PATCH + accepts (dependabot_security_updates is enable_automated_security_fixes' own state); the GET's server fields (ids, urls, counts, + has_downloads) are not PATCHable and fall away. Topics are read back when any are set, each + readable toggle as GitHub reports it (an owner-enforced immutable-releases toggle is noted as + immovable; when every toggle GET answers 404 the four are left out under a note, since a + fine-grained token missing the grant is answered the same way), and the two GraphQL keys; Git LFS has no read and is noted, and a toggle or + GraphQL read the token is denied is noted and left out. - area: >- [security_and_analysis](https://docs.github.com/en/rest/repos/repos) (GitHub Advanced Security, secret scanning, push protection, and any nested toggle GitHub adds) diff --git a/src/sections/repository/repository.test.ts b/src/sections/repository/repository.test.ts index 6a8e22c1..c7acd716 100644 --- a/src/sections/repository/repository.test.ts +++ b/src/sections/repository/repository.test.ts @@ -8,6 +8,7 @@ import { REPO } from "../../../test/sections/section-run.js"; import { validateSectionShapes } from "../../engine/validate.js"; import { describeProblem } from "../../problem.js"; import { PermissionDenied } from "../contract/errors.js"; +import { sectionGrant } from "../contract/module.js"; import { FEATURE_TOGGLES, normalizeTopics, repositorySection } from "./index.js"; function shapeError(doc: Record, sourceLabel: string): string | null { @@ -789,3 +790,178 @@ describe("repository GraphQL-routed keys", () => { } }); }); + +describe("repository snapshot", () => { + const snapshot = (api: GithubClient) => + repositorySection.snapshot(planContext(repositorySection, api, REPO)); + const LFS_NOTE = + "repository.enable_git_lfs: GitHub exposes no endpoint to read Git LFS back, so the snapshot leaves it out; declare it yourself to manage it"; + + test("reads the PATCH fields, topics, toggles, and GraphQL keys back; what nobody can PATCH and a null field fall away", async () => { + const api = liveRepo({ + repo: { + id: 500, + node_id: "R_kgDOHdPQ2A", + name: "r", + full_name: "o/r", + owner: { login: "o", id: 9 }, + html_url: "https://github.com/o/r", + forks_count: 3, + has_downloads: true, + permissions: { admin: true }, + description: "Docs", + homepage: null, + private: false, + visibility: "public", + security_and_analysis: { secret_scanning: { status: "enabled" } }, + has_issues: true, + has_wiki: false, + default_branch: "main", + allow_squash_merge: true, + squash_merge_commit_title: "PR_TITLE", + archived: false, + topics: ["ci", "tooling"], + }, + toggles: { "vulnerability-alerts": true, "automated-security-fixes": true }, + features: { hasSponsorshipsEnabled: true, issueCreationPolicy: "COLLABORATORS_ONLY" }, + }); + expect(await snapshot(api)).toEqual({ + value: { + description: "Docs", + private: false, + visibility: "public", + security_and_analysis: { secret_scanning: { status: "enabled" } }, + has_issues: true, + has_wiki: false, + default_branch: "main", + allow_squash_merge: true, + squash_merge_commit_title: "PR_TITLE", + archived: false, + topics: ["ci", "tooling"], + enable_vulnerability_alerts: true, + enable_automated_security_fixes: true, + enable_private_vulnerability_reporting: false, + enable_immutable_releases: false, + enable_sponsorships: true, + issue_creation_policy: "collaborators_only", + }, + notes: [LFS_NOTE], + }); + expect(api.writes).toEqual([]); + }); + + test("security_and_analysis keeps only its PATCHable sub-keys: dependabot_security_updates is the toggle's, not the passthrough's", async () => { + const api = liveRepo({ + repo: { + security_and_analysis: { + secret_scanning: { status: "enabled" }, + secret_scanning_push_protection: { status: "disabled" }, + dependabot_security_updates: { status: "enabled" }, + }, + topics: [], + }, + toggles: { "automated-security-fixes": true }, + }); + const { value } = await snapshot(api); + expect(value?.security_and_analysis).toEqual({ + secret_scanning: { status: "enabled" }, + secret_scanning_push_protection: { status: "disabled" }, + }); + expect(value?.enable_automated_security_fixes).toBe(true); + // Only the toggle's own key carries the state, so nothing about it is declared twice. + const bare = liveRepo({ + repo: { + security_and_analysis: { dependabot_security_updates: { status: "enabled" } }, + topics: [], + }, + }); + expect((await snapshot(bare)).value).not.toHaveProperty("security_and_analysis"); + }); + + test("a denied toggle probe is a note, an owner-enforced toggle reads back with a note, and an unreadable policy is left out", async () => { + const api = new MockApi({ + [GET]: { data: { description: "x", topics: [] } }, + "GET /repos/o/r/private-vulnerability-reporting": { + error: { status: 403, message: "Forbidden", body: "" }, + }, + "GET /repos/o/r/immutable-releases": { data: { enabled: true, enforced_by_owner: true } }, + ...features({ issueCreationPolicy: "NOBODY" }), + }); + expect(await snapshot(api)).toEqual({ + value: { + description: "x", + enable_vulnerability_alerts: false, + enable_automated_security_fixes: false, + enable_immutable_releases: true, + enable_sponsorships: false, + }, + notes: [ + `repository.enable_private_vulnerability_reporting: left out of the snapshot - the token was denied GET /repos/o/r/private-vulnerability-reporting: 403 Forbidden. To fix, ${sectionGrant(repositorySection)}`, + "repository.enable_immutable_releases: the repository owner enforces immutable releases, so it reads back as true but cannot be changed from the repository", + LFS_NOTE, + 'repository.issue_creation_policy: GRAPHQL RepositoryFeatures returned issueCreationPolicy "NOBODY", ' + + "which this section cannot read as a repository.issue_creation_policy value; a null policy means " + + "GitHub reported no issue creation policy for this repository; otherwise the field vocabulary may " + + "have changed, so the snapshot leaves it out", + ], + }); + expect(api.mutations()).toEqual([]); + }); + + test("four toggle 404s are left out under one note, since a concealed denial answers the same; one other answer proves the grant", async () => { + // Every unrouted GET answers 404, as a fine-grained token without the grant is answered. + const allOff = new MockApi({ [GET]: { data: { topics: [] } }, ...features() }); + expect(await snapshot(allOff)).toEqual({ + value: { enable_sponsorships: false, issue_creation_policy: "all" }, + notes: [ + `repository.enable_vulnerability_alerts/enable_automated_security_fixes/enable_private_vulnerability_reporting/enable_immutable_releases: every toggle GET answered 404, which reads as off but is also how a fine-grained token missing the grant is answered, so they are left out; if the token does ${sectionGrant(repositorySection)}, they are all off and can be declared false`, + LFS_NOTE, + ], + }); + const oneOn = new MockApi({ + [GET]: { data: { topics: [] } }, + "GET /repos/o/r/immutable-releases": { data: { enabled: true, enforced_by_owner: false } }, + ...features(), + }); + expect((await snapshot(oneOn)).value).toMatchObject({ + enable_vulnerability_alerts: false, + enable_automated_security_fixes: false, + enable_private_vulnerability_reporting: false, + enable_immutable_releases: true, + }); + // The declared 422 ("not applicable") is answered only to a granted token, so it proves it too. + const notApplicable = new MockApi({ + [GET]: { data: { topics: [] } }, + "GET /repos/o/r/private-vulnerability-reporting": { + error: { status: 422, message: "Unprocessable", body: "" }, + }, + ...features(), + }); + expect((await snapshot(notApplicable)).value).toMatchObject({ + enable_vulnerability_alerts: false, + enable_automated_security_fixes: false, + enable_private_vulnerability_reporting: false, + enable_immutable_releases: false, + }); + }); + + test("a denied features query leaves both GraphQL keys out under one note", async () => { + const api = new MockApi({ + [GET]: { data: { topics: [] } }, + "GET /repos/o/r/private-vulnerability-reporting": { data: { enabled: false } }, + "GRAPHQL RepositoryFeatures": { error: { status: 403, message: "Forbidden", body: "" } }, + }); + expect(await snapshot(api)).toEqual({ + value: { + enable_vulnerability_alerts: false, + enable_automated_security_fixes: false, + enable_private_vulnerability_reporting: false, + enable_immutable_releases: false, + }, + notes: [ + LFS_NOTE, + `repository.enable_sponsorships and repository.issue_creation_policy: left out of the snapshot - the token was denied GRAPHQL RepositoryFeatures: 403 Forbidden. To fix, ${sectionGrant(repositorySection)}`, + ], + }); + }); +}); diff --git a/src/sections/rulesets/index.ts b/src/sections/rulesets/index.ts index 705186e6..37dd2623 100644 --- a/src/sections/rulesets/index.ts +++ b/src/sections/rulesets/index.ts @@ -16,6 +16,11 @@ import type { SectionPermission } from "../contract/permissions.js"; import { hasDrift, type PlannedOp, plainData, type SectionPlan } from "../contract/plan.js"; import { rejectDuplicates } from "../contract/requests.js"; import { knobbed } from "../shared/schema-helpers.js"; +import { + knobbedSnapshot, + projectOntoSchema, + rejectLiveDuplicates, +} from "../shared/snapshot-helpers.js"; import { RulesetConfig } from "./schema.js"; /** @@ -60,20 +65,33 @@ const LiveRulesetSummary = z.looseObject({ source_type: z.string().optional(), }); +type LiveRulesetSummary = z.infer; + const permission: SectionPermission = { repo: ["administration"] }; /** - * GitHub returns a ruleset's bypass_actors only to a token with write access to the ruleset - * (Administration at write); any other GET omits the KEY, never `[]`. Returns the input itself when - * nothing is hidden, so the caller can tell the two apart by identity. + * A summary this section may manage: anything not explicitly owned by another source (source_type + * is optional in the API type). Deletion is gated harder in plan(): only an explicit "Repository" + * is ever deleted, since a missing field is not proof. + */ +function ownedByRepository(summary: LiveRulesetSummary): boolean { + return (summary.source_type ?? "Repository") === "Repository"; +} + +const BYPASS_HIDDEN = + "bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset)"; + +/** GitHub omits the bypass_actors KEY (never `[]`) from a GET the token lacks write access for. */ +function bypassActorsVisible(live: unknown): boolean { + return typeof live === "object" && live !== null && Object.hasOwn(live, "bypass_actors"); +} + +/** + * The declared ruleset minus a bypass_actors list the token cannot observe, so it reads as neither + * drift nor a phantom key. Returns the input itself when nothing is hidden, so the caller can tell. */ function observableRuleset(ruleset: RulesetConfig, live: unknown): RulesetConfig { - const hidden = - Object.hasOwn(ruleset, "bypass_actors") && - typeof live === "object" && - live !== null && - !Object.hasOwn(live, "bypass_actors"); - if (!hidden) { + if (!Object.hasOwn(ruleset, "bypass_actors") || bypassActorsVisible(live)) { return ruleset; } const { bypass_actors: _hidden, ...visible } = ruleset; @@ -145,10 +163,7 @@ export const rulesetsSection = { z.array(LiveRulesetSummary), await ctx.read.list.listAll(), ); - // Anything not explicitly owned by another source is matched and updated (source_type is - // optional in the API type). Deletion is gated harder below: only a summary the API explicitly - // marks source_type "Repository" is ever deleted, since a missing field is not proof of ownership. - const repoRulesets = summaries.filter((r) => (r.source_type ?? "Repository") === "Repository"); + const repoRulesets = summaries.filter(ownedByRepository); const idByName = new Map(repoRulesets.map((r) => [r.name, r.id])); const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; @@ -172,7 +187,7 @@ export const rulesetsSection = { const compared = observableRuleset(ruleset, live); if (compared !== ruleset) { plan.notes.push( - `rulesets[${ruleset.name}]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), so drift on it cannot be judged here; grant Administration write to check it`, + `rulesets[${ruleset.name}]: ${BYPASS_HIDDEN}, so drift on it cannot be judged here; grant Administration write to check it`, ); } const drift = subsetDiff(compared, live, `rulesets[${ruleset.name}]`); @@ -228,4 +243,46 @@ export const rulesetsSection = { } return plan; }, + // A GET without the bypass_actors key means the token cannot see them; an entry without the + // key would erase them on the next full-payload PUT, so the ruleset stays undeclared (kept). + async snapshot(ctx) { + const summaries = parseLive( + this, + ENDPOINTS.list, + z.array(LiveRulesetSummary), + await ctx.read.list.listAll(), + ); + const notes = summaries + .filter((summary) => !ownedByRepository(summary)) + .map( + (summary) => + `rulesets[${summary.name}]: inherited from the ${String(summary.source_type).toLowerCase()} (source_type "${summary.source_type}"), so it is not part of the repository's snapshot; manage it where it is defined`, + ); + const owned = summaries.filter(ownedByRepository); + // plan() upserts by name, so two live rulesets under one name have no declarable form. + rejectLiveDuplicates( + this, + "ruleset", + owned, + (summary) => summary.name, + (summary) => `${summary.name} (id ${summary.id})`, + ); + const entries: RulesetConfig[] = []; + for (const summary of owned) { + const live = await ctx.read.get.call({ params: { ruleset_id: String(summary.id) } }); + if (!bypassActorsVisible(live)) { + notes.push( + `rulesets[${summary.name}]: ${BYPASS_HIDDEN}, and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); grant Administration write to read it back`, + ); + continue; + } + entries.push(projectOntoSchema(RulesetConfig, live)); + } + // Owned rulesets that were all left out still exist: an empty keep wrapper says so, where + // `undefined` would render as "nothing exists on the repository". + if (owned.length === 0) { + return { value: undefined, notes }; + } + return { value: knobbedSnapshot(this, entries), notes }; + }, } satisfies SectionModule<"rulesets", typeof ENDPOINTS>; diff --git a/src/sections/rulesets/rulesets.docs.yml b/src/sections/rulesets/rulesets.docs.yml index b51abbcd..803de73c 100644 --- a/src/sections/rulesets/rulesets.docs.yml +++ b/src/sections/rulesets/rulesets.docs.yml @@ -15,7 +15,11 @@ coverage: Org-sourced rulesets filtered out via source_type. GitHub returns bypass_actors only to a token with write access to the ruleset, so under a token without Administration write a declared bypass_actors is left out of the comparison and noted as not visible instead of - reported as drift. + reported as drift. Snapshot reads repository rulesets back as entries under the keep default + (id, source, links, timestamps, and the caller's bypass state fall away); an inherited + organization ruleset is noted, not declared, and a ruleset whose bypass_actors the token cannot + see is left out with a note (kept undeclared), since an entry without the key would clear the + bypass list on the next full-payload update. - area: "[Merge queue](https://docs.github.com/en/rest/repos/rules)" notes: >- Configured as the merge_queue rule type inside a branch ruleset; passes through verbatim like diff --git a/src/sections/rulesets/rulesets.test.ts b/src/sections/rulesets/rulesets.test.ts index 859c2e24..8238a05e 100644 --- a/src/sections/rulesets/rulesets.test.ts +++ b/src/sections/rulesets/rulesets.test.ts @@ -546,3 +546,117 @@ describe("rulesets", () => { const _silent: Op = silent; }); }); + +describe("rulesets snapshot", () => { + const snapshot = (api: GithubClient) => + rulesetsSection.snapshot(planContext(rulesetsSection, api, REPO)); + + /** A live ruleset as the by-id GET returns it, server fields included. */ + const served = ( + id: number, + name: string, + body: Record, + ): Record & { id: number; name: string } => ({ + id, + name, + node_id: `RRS_${id}`, + source_type: "Repository", + source: "o/r", + _links: { self: { href: `https://api.github.com/repos/o/r/rulesets/${id}` } }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + current_user_can_bypass: "always", + ...body, + }); + + test("reads repository rulesets back as keep entries, dropping server fields; a hidden bypass list and an inherited ruleset are notes, not entries", async () => { + const api = liveRepo([ + served(1, "main", { + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "pull_request", parameters: { required_approving_review_count: 1 } }, + ], + bypass_actors: [{ actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always" }], + }), + // No bypass_actors KEY: what a token without write access is served. + served(2, "tags", { + target: "tag", + enforcement: "evaluate", + conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, + rules: [{ type: "update" }], + }), + { + ...served(3, "org-wide", { target: "branch", enforcement: "active" }), + source_type: "Organization", + }, + ]); + expect(await snapshot(api)).toEqual({ + value: { + _undeclared: "keep", + entries: [ + { + name: "main", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "pull_request", parameters: { required_approving_review_count: 1 } }, + ], + bypass_actors: [{ actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always" }], + }, + ], + }, + notes: [ + 'rulesets[org-wide]: inherited from the organization (source_type "Organization"), so it is not part of the repository\'s snapshot; manage it where it is defined', + "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); grant Administration write to read it back", + ], + }); + expect(api.writes).toEqual([]); + }); + + test("two repository rulesets under one name fail the snapshot naming both, since plan() upserts by name", async () => { + const api = liveRepo([ + served(1, "main", { target: "branch", enforcement: "active" }), + served(2, "main", { target: "tag", enforcement: "active" }), + ]); + await expect(snapshot(api)).rejects.toThrow( + 'rulesets: GitHub holds rulesets that resolve to one identity: "main (id 1)" and "main (id 2)". This section manages one ruleset per identity, so the snapshot cannot declare them; delete all but one of each on GitHub, then snapshot again', + ); + }); + + test("an owned ruleset left out for its hidden bypass list leaves an empty keep wrapper, which plans as a no-op", async () => { + const api = liveRepo([served(2, "tags", { target: "tag", enforcement: "evaluate" })]); + const read = await snapshot(api); + expect(read).toEqual({ + value: { _undeclared: "keep", entries: [] }, + notes: [ + "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); grant Administration write to read it back", + ], + }); + const planned = await rulesetsSection.plan( + planContext(rulesetsSection, api, REPO), + read.value as NonNullable, + ); + expect({ ops: planned.ops, drift: planned.drift }).toEqual({ ops: [], drift: [] }); + expect(api.writes).toEqual([]); + }); + + test("only inherited rulesets is nothing to declare, with the inherited note", async () => { + const api = liveRepo([ + { + ...served(3, "org-wide", { target: "branch", enforcement: "active" }), + source_type: "Organization", + }, + ]); + expect(await snapshot(api)).toEqual({ + value: undefined, + notes: [ + 'rulesets[org-wide]: inherited from the organization (source_type "Organization"), so it is not part of the repository\'s snapshot; manage it where it is defined', + ], + }); + }); +}); diff --git a/src/sections/shared/snapshot-helpers.ts b/src/sections/shared/snapshot-helpers.ts index cba807b6..24e926fc 100644 --- a/src/sections/shared/snapshot-helpers.ts +++ b/src/sections/shared/snapshot-helpers.ts @@ -142,7 +142,6 @@ export function rejectLiveDuplicates( * One read of a snapshot that a denial may take out without failing the section: a * PermissionDenied becomes a note naming the key left out and the grant advice, anything else * propagates. For a section whose keys sit behind different grants (repository, actions). - * @public */ export async function readOrNote( notes: string[], diff --git a/test/engine/snapshot.test.ts b/test/engine/snapshot.test.ts index 2c6bb4c1..a0702cac 100644 --- a/test/engine/snapshot.test.ts +++ b/test/engine/snapshot.test.ts @@ -88,7 +88,9 @@ describe("snapshotRepository", () => { expect(api.writes).toEqual([]); // The document holds exactly the sections with live state, in registry order. expect(Object.keys(result.settings ?? {})).toEqual([ + "repository", "labels", + "actions", "actions_secrets", "workflows", "code_scanning_default_setup", @@ -105,7 +107,7 @@ describe("snapshotRepository", () => { }); // Every registered section has exactly one outcome, unsupported ones with their reason. expect(result.outcomes.map((o) => o.key)).toEqual(SECTIONS.map((s) => s.key)); - expect(UNSUPPORTED).toEqual(["repository", "rulesets", "actions", "check_suite_preferences"]); + expect(UNSUPPORTED).toEqual(["check_suite_preferences"]); expect( result.outcomes.filter((o) => o.status === "unsupported").map((o) => [o.key, o.detail]), ).toEqual( @@ -195,13 +197,13 @@ describe("snapshotRepository", () => { api, { ...opts(), - sections: SectionSelection.of({ only: ["labels", "repository"] })._unsafeUnwrap(), + sections: SectionSelection.of({ only: ["labels", "check_suite_preferences"] })._unsafeUnwrap(), }, captureIo().io, ); expect(result.result).toBe("snapshot"); expect(result.outcomes.map((o) => [o.key, o.status])).toEqual([ - ["repository", "unsupported"], + ["check_suite_preferences", "unsupported"], ["labels", "snapshot"], ]); expect(Object.keys(result.settings ?? {})).toEqual(["labels"]); diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts index 16903bab..e44c28bc 100644 --- a/test/scripts/changed-sections.test.ts +++ b/test/scripts/changed-sections.test.ts @@ -116,9 +116,12 @@ describe("changed-sections derived fan-out", () => { // The snapshot projection and knobbed wrapper: every section with a snapshot() except // workflows and teams, whose two-field entries are spelled by hand. "snapshot-helpers.ts": inKeyOrder( + "repository", "labels", + "rulesets", "environments", "autolinks", + "actions", "actions_secrets", "dependabot_secrets", "codespaces_secrets", diff --git a/test/sections/snapshot-rows/actions.ts b/test/sections/snapshot-rows/actions.ts new file mode 100644 index 00000000..a1cb990f --- /dev/null +++ b/test/sections/snapshot-rows/actions.ts @@ -0,0 +1,50 @@ +import { actionsSection } from "../../../src/sections/actions/index.js"; +import type { Row } from "../snapshot-roundtrip.js"; + +// The unseeded endpoints answer the mock's GitHub-default bodies, which read back too. +export const row: Row = { + section: actionsSection, + live: { + actions_permissions: { + enabled: true, + allowed_actions: "selected", + selected_actions_url: "https://api.github.com/repos/o/r/actions/permissions/selected-actions", + }, + selected_actions: { + github_owned_allowed: true, + verified_allowed: true, + patterns_allowed: ["docker/*"], + }, + workflow_permissions: { + default_workflow_permissions: "write", + can_approve_pull_request_reviews: true, + }, + actions_access: { access_level: "user" }, + actions_retention: { days: 14, maximum_allowed_days: 400 }, + }, + expected: { + value: { + enabled: true, + allowed_actions: "selected", + default_workflow_permissions: "write", + can_approve_pull_request_reviews: true, + selected_actions: { + github_owned_allowed: true, + verified_allowed: true, + patterns_allowed: ["docker/*"], + }, + access_level: "user", + artifact_and_log_retention: { days: 14 }, + cache: { max_cache_retention_days: 7, max_cache_size_gb: 10 }, + oidc_customization_sub: { use_default: true }, + fork_pr_contributor_approval: { approval_policy: "first_time_contributors_new_to_github" }, + fork_pr_workflows_private_repos: { + run_workflows_from_fork_pull_requests: false, + send_write_tokens_to_workflows: false, + send_secrets_and_variables: false, + require_approval_for_fork_pr_workflows: true, + }, + }, + notes: [], + }, +}; diff --git a/test/sections/snapshot-rows/repository.ts b/test/sections/snapshot-rows/repository.ts new file mode 100644 index 00000000..03bbd7c8 --- /dev/null +++ b/test/sections/snapshot-rows/repository.ts @@ -0,0 +1,57 @@ +import { repositorySection } from "../../../src/sections/repository/index.js"; +import type { Row } from "../snapshot-roundtrip.js"; + +// Over the repo fixture (ids, urls, counts, owner, license, has_downloads: none PATCHable); the +// overlay adds a null homepage, a passthrough field, two toggles, and the GraphQL fields. +export const row: Row = { + section: repositorySection, + live: { + repo: { + homepage: null, + allow_forking: true, + security_and_analysis: { secret_scanning: { status: "enabled" } }, + vulnerability_alerts_enabled: true, + immutable_releases_enabled: true, + has_sponsorships_enabled: true, + issue_creation_policy: "COLLABORATORS_ONLY", + }, + }, + expected: { + value: { + description: "End-to-end fixture repository for settings-as-code.", + private: false, + visibility: "public", + security_and_analysis: { secret_scanning: { status: "enabled" } }, + has_issues: true, + has_projects: true, + has_wiki: true, + has_discussions: false, + is_template: false, + default_branch: "main", + allow_squash_merge: true, + allow_merge_commit: false, + allow_rebase_merge: true, + allow_auto_merge: false, + delete_branch_on_merge: true, + allow_update_branch: true, + use_squash_pr_title_as_default: true, + squash_merge_commit_title: "PR_TITLE", + squash_merge_commit_message: "PR_BODY", + merge_commit_title: "MERGE_MESSAGE", + merge_commit_message: "PR_TITLE", + archived: false, + allow_forking: true, + web_commit_signoff_required: false, + topics: ["settings-as-code", "automation"], + enable_vulnerability_alerts: true, + enable_automated_security_fixes: false, + enable_private_vulnerability_reporting: false, + enable_immutable_releases: true, + enable_sponsorships: true, + issue_creation_policy: "collaborators_only", + }, + notes: [ + "repository.enable_git_lfs: GitHub exposes no endpoint to read Git LFS back, so the snapshot leaves it out; declare it yourself to manage it", + ], + }, +}; diff --git a/test/sections/snapshot-rows/rulesets.ts b/test/sections/snapshot-rows/rulesets.ts new file mode 100644 index 00000000..85db202d --- /dev/null +++ b/test/sections/snapshot-rows/rulesets.ts @@ -0,0 +1,62 @@ +import { rulesetsSection } from "../../../src/sections/rulesets/index.js"; +import type { Row } from "../snapshot-roundtrip.js"; + +// The all-grades token sees bypass_actors; the server fields fall away; the organization's ruleset +// is noted, not declared. +export const row: Row = { + section: rulesetsSection, + live: { + rulesets: [ + { + id: 42, + name: "protect-main", + node_id: "RRS_lACqUmVwb3NpdG9yeQ", + source_type: "Repository", + source: "o/r", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "pull_request", parameters: { required_approving_review_count: 2 } }, + ], + bypass_actors: [{ actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always" }], + current_user_can_bypass: "always", + _links: { self: { href: "https://api.github.com/repos/o/r/rulesets/42" } }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-02-01T00:00:00Z", + }, + { + id: 43, + name: "org-baseline", + source_type: "Organization", + source: "o", + target: "branch", + enforcement: "active", + rules: [{ type: "non_fast_forward" }], + }, + ], + }, + expected: { + value: { + _undeclared: "keep", + entries: [ + { + name: "protect-main", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "pull_request", parameters: { required_approving_review_count: 2 } }, + ], + bypass_actors: [{ actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always" }], + }, + ], + }, + notes: [ + 'rulesets[org-baseline]: inherited from the organization (source_type "Organization"), ' + + "so it is not part of the repository's snapshot; manage it where it is defined", + ], + }, +}; From 51a90ce44f17c0710ab1e170de1c67c81e7f2cb1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:11:24 -0400 Subject: [PATCH 2/7] test(snapshot): round-trip scenarios for repository, actions, and rulesets --- .../scenarios/actions-snapshot-roundtrip.yml | 84 ++++++++++++++ .../repository-snapshot-roundtrip.yml | 73 +++++++++++++ .../scenarios/rulesets-snapshot-roundtrip.yml | 103 ++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 src/sections/actions/scenarios/actions-snapshot-roundtrip.yml create mode 100644 src/sections/repository/scenarios/repository-snapshot-roundtrip.yml create mode 100644 src/sections/rulesets/scenarios/rulesets-snapshot-roundtrip.yml diff --git a/src/sections/actions/scenarios/actions-snapshot-roundtrip.yml b/src/sections/actions/scenarios/actions-snapshot-roundtrip.yml new file mode 100644 index 00000000..e11274dc --- /dev/null +++ b/src/sections/actions/scenarios/actions-snapshot-roundtrip.yml @@ -0,0 +1,84 @@ +name: actions-snapshot-roundtrip +description: "mode: snapshot reads the live Actions settings back into snapshot.yml and writes nothing to GitHub. Every endpoint reads back onto its key: the permissions policy with its selected-actions allowlist, the workflow token defaults, the access level, the retention and cache limits, a customized OIDC subject claim, and both fork pull request policies; selected_actions_url and maximum_allowed_days fall away. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + actions_permissions: + enabled: true + allowed_actions: selected + selected_actions_url: https://api.github.com/repos/e2e-owner/e2e-repo/actions/permissions/selected-actions + selected_actions: + github_owned_allowed: true + verified_allowed: true + patterns_allowed: + - docker/* + workflow_permissions: + default_workflow_permissions: write + can_approve_pull_request_reviews: true + actions_access: + access_level: user + actions_retention: + days: 14 + maximum_allowed_days: 400 + cache_retention_limit: + max_cache_retention_days: 5 + cache_storage_limit: + max_cache_size_gb: 20 + oidc_customization_sub: + use_default: false + include_claim_keys: + - repo + - context + fork_pr_contributor_approval: + approval_policy: all_external_contributors + fork_pr_workflows_private_repos: + run_workflows_from_fork_pull_requests: true + send_write_tokens_to_workflows: false + send_secrets_and_variables: false + require_approval_for_fork_pr_workflows: true +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: actions +token_permissions: + administration: write + actions: write +expect: + exit_code: 0 + result: snapshot + outcomes: + actions: snapshot + snapshot: + actions: + enabled: true + allowed_actions: selected + default_workflow_permissions: write + can_approve_pull_request_reviews: true + selected_actions: + github_owned_allowed: true + verified_allowed: true + patterns_allowed: + - docker/* + access_level: user + artifact_and_log_retention: + days: 14 + cache: + max_cache_retention_days: 5 + max_cache_size_gb: 20 + oidc_customization_sub: + use_default: false + include_claim_keys: + - repo + - context + fork_pr_contributor_approval: + approval_policy: all_external_contributors + fork_pr_workflows_private_repos: + run_workflows_from_fork_pull_requests: true + send_write_tokens_to_workflows: false + send_secrets_and_variables: false + require_approval_for_fork_pr_workflows: true + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/repository/scenarios/repository-snapshot-roundtrip.yml b/src/sections/repository/scenarios/repository-snapshot-roundtrip.yml new file mode 100644 index 00000000..57e2bc27 --- /dev/null +++ b/src/sections/repository/scenarios/repository-snapshot-roundtrip.yml @@ -0,0 +1,73 @@ +name: repository-snapshot-roundtrip +description: "mode: snapshot reads the live repository back into snapshot.yml and writes nothing to GitHub. The GET's documented PATCH fields, the topics, every readable security toggle, and the two GraphQL-only keys read back; ids, urls, counts, the owner, the license, and has_downloads fall away, and Git LFS is noted since GitHub exposes no read for it. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + repo: + homepage: https://example.com/docs + allow_forking: true + security_and_analysis: + secret_scanning: + status: enabled + automated_security_fixes_enabled: true + vulnerability_alerts_enabled: true + private_vulnerability_reporting_enabled: false + immutable_releases_enabled: true + has_sponsorships_enabled: true + issue_creation_policy: COLLABORATORS_ONLY +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: repository +token_permissions: + administration: write +expect: + exit_code: 0 + result: snapshot + outcomes: + repository: snapshot + snapshot: + repository: + description: End-to-end fixture repository for settings-as-code. + homepage: https://example.com/docs + private: false + visibility: public + security_and_analysis: + secret_scanning: + status: enabled + has_issues: true + has_projects: true + has_wiki: true + has_discussions: false + is_template: false + default_branch: main + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: true + allow_auto_merge: false + delete_branch_on_merge: true + allow_update_branch: true + use_squash_pr_title_as_default: true + squash_merge_commit_title: PR_TITLE + squash_merge_commit_message: PR_BODY + merge_commit_title: MERGE_MESSAGE + merge_commit_message: PR_TITLE + archived: false + allow_forking: true + web_commit_signoff_required: false + topics: + - settings-as-code + - automation + enable_vulnerability_alerts: true + enable_automated_security_fixes: true + enable_private_vulnerability_reporting: false + enable_immutable_releases: true + enable_sponsorships: true + issue_creation_policy: collaborators_only + stdout_contains: + - "repository.enable_git_lfs: GitHub exposes no endpoint to read Git LFS back" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/rulesets/scenarios/rulesets-snapshot-roundtrip.yml b/src/sections/rulesets/scenarios/rulesets-snapshot-roundtrip.yml new file mode 100644 index 00000000..43a08745 --- /dev/null +++ b/src/sections/rulesets/scenarios/rulesets-snapshot-roundtrip.yml @@ -0,0 +1,103 @@ +name: rulesets-snapshot-roundtrip +description: "mode: snapshot reads the live rulesets back into snapshot.yml and writes nothing to GitHub. Each repository-owned ruleset reads back as a keep entry with its target, enforcement, ref conditions, rules, and the bypass_actors the write-grade token sees; ids, the source fields, links, and timestamps fall away, and the organization's inherited ruleset is a note, not an entry. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + rulesets: + - id: 42 + name: protect-main + node_id: RRS_lACqUmVwb3NpdG9yeQ + source_type: Repository + source: e2e-owner/e2e-repo + target: branch + enforcement: active + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + rules: + - type: deletion + - type: pull_request + parameters: + required_approving_review_count: 2 + bypass_actors: + - actor_id: 5 + actor_type: RepositoryRole + bypass_mode: always + current_user_can_bypass: always + _links: + self: + href: https://api.github.com/repos/e2e-owner/e2e-repo/rulesets/42 + created_at: "2026-01-01T00:00:00Z" + updated_at: "2026-02-01T00:00:00Z" + - id: 44 + name: release-tags + source_type: Repository + source: e2e-owner/e2e-repo + target: tag + enforcement: evaluate + conditions: + ref_name: + include: ["refs/tags/v*"] + exclude: [] + rules: + - type: update + - type: non_fast_forward + bypass_actors: [] + - id: 900 + name: org-baseline + source_type: Organization + source: e2e-owner + target: branch + enforcement: active + rules: + - type: non_fast_forward +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: rulesets +token_permissions: + administration: write +expect: + exit_code: 0 + result: snapshot + outcomes: + rulesets: snapshot + snapshot: + rulesets: + _undeclared: keep + entries: + - name: protect-main + target: branch + enforcement: active + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + rules: + - type: deletion + - type: pull_request + parameters: + required_approving_review_count: 2 + bypass_actors: + - actor_id: 5 + actor_type: RepositoryRole + bypass_mode: always + - name: release-tags + target: tag + enforcement: evaluate + conditions: + ref_name: + include: ["refs/tags/v*"] + exclude: [] + rules: + - type: update + - type: non_fast_forward + bypass_actors: [] + stdout_contains: + - "rulesets[org-baseline]: inherited from the organization" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true From 7cffa37f43c58840316e01809c550186351a2860 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:12:24 -0400 Subject: [PATCH 3/7] docs(snapshot): list the repository, actions, and rulesets round-trip exceptions --- docs/operate/snapshot.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index 0a0ae914..f239dc42 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -121,6 +121,12 @@ A snapshot is written so that applying it changes nothing and checking it reads | `webhooks[].config.secret` | GitHub echoes `********` | A `$WEBHOOK_SECRET_N` reference and a note per hook | | `interaction_limits.expiry` | GitHub reports only the computed `expires_at` | No `expiry` key; apply re-arms the limit with GitHub's default unless you declare one | | `check_suite_preferences` | GitHub exposes no read endpoint | Nothing; the header says so | +| `repository.enable_git_lfs` | GitHub exposes no read endpoint | Nothing; the header says so, and apply re-asserts the declared value on every run | +| `repository.enable_*` toggles under a token whose every toggle probe answers 404 | A fine-grained token missing the Administration grant is answered like a disabled toggle | Nothing for the four toggles, under one header line | +| `actions.` the token cannot read | A sub-endpoint has its own grant (the OIDC template needs Actions) | Nothing for that key; the header names it, and the other keys read back | +| `rulesets[]` whose `bypass_actors` the token cannot see | GitHub returns the list only to a write-grade token | No entry (kept under `_undeclared: keep`) and a header line; an entry without the list would clear it on the next update | +| Organization and enterprise rulesets | Inherited, not the repository's to manage | Nothing; a header line names each | +| `repository` | With `snapshot-file`: the target, defaulting to the current repository. Rejected with `snapshot-dir` | | `collaborators`: the repository owner, email invitations | The owner's access is implicit, and an email invitation has no username to declare | No entry and a note each; apply leaves both alone | | `collaborators`: expired invitations | A declared one would be cancelled and re-sent; an undeclared one is cancelled under the delete default the file carries | No entry and a note per invitation; add the entry to re-invite | | A custom role named `push` or `pull` | In a settings file those words mean the `write` and `read` roles, so no declaration plans as the live role | `collaborators` fails and the file is written without it; `teams` omits the team with a note | From 6facec3c9403ac30a1d28ddd2312510a6ade29b1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:17:56 -0400 Subject: [PATCH 4/7] refactor(rulesets): name the manageable-ruleset predicate for what it tests, and table the repository snapshot rules --- COVERAGE.md | 2 +- docs/operate/snapshot.md | 4 ++++ src/sections/repository/repository.docs.yml | 13 ++++-------- src/sections/rulesets/index.ts | 22 ++++++++++----------- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index 8e3d7268..6cf934f0 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -7,7 +7,7 @@ The tenet: this action can control everything about a repository and nothing abo | Area | Section | Notes | |---|---|---| -| [Repository core settings](https://docs.github.com/en/rest/repos/repos) (name, description, homepage, visibility/private, has_issues, has_wiki, has_projects, has_discussions, merge-strategy toggles, squash/merge commit title+message, delete_branch_on_merge, allow_update_branch, allow_auto_merge, web_commit_signoff_required, is_template, archived, default_branch) | `repository` | PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of declared keys against GET /repos. Snapshot reads back every documented PATCH field the GET reports except `name` (the repository's identity; a reused file would rename its target) and any null field, plus has_discussions; inside security_and_analysis only the sub-keys the PATCH accepts (dependabot_security_updates is enable_automated_security_fixes' own state); the GET's server fields (ids, urls, counts, has_downloads) are not PATCHable and fall away. Topics are read back when any are set, each readable toggle as GitHub reports it (an owner-enforced immutable-releases toggle is noted as immovable; when every toggle GET answers 404 the four are left out under a note, since a fine-grained token missing the grant is answered the same way), and the two GraphQL keys; Git LFS has no read and is noted, and a toggle or GraphQL read the token is denied is noted and left out. | +| [Repository core settings](https://docs.github.com/en/rest/repos/repos) (name, description, homepage, visibility/private, has_issues, has_wiki, has_projects, has_discussions, merge-strategy toggles, squash/merge commit title+message, delete_branch_on_merge, allow_update_branch, allow_auto_merge, web_commit_signoff_required, is_template, archived, default_branch) | `repository` | PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of declared keys against GET /repos. Snapshot reads back the documented PATCH fields the GET reports, the topics, each readable toggle, and the two GraphQL keys; the server-only fields (ids, urls, counts, has_downloads) fall away. The fields it leaves out or notes are listed in docs/operate/snapshot.md. | | [security_and_analysis](https://docs.github.com/en/rest/repos/repos) (GitHub Advanced Security, secret scanning, push protection, and any nested toggle GitHub adds) | `repository` | Nested object inside the same PATCH /repos passthrough; flows through untouched. New sub-toggles GitHub adds (e.g. secret_scanning_ai_detection) work automatically. That includes the delegated-bypass surface: secret_scanning_delegated_bypass and secret_scanning_delegated_bypass_options (reviewers as {reviewer_id, reviewer_type, mode}) nest here per the OpenAPI spec, covering the "who can bypass push protection" UI including per-actor Exempt mode, and the GET echoes them so check mode verifies them. secret_scanning_validity_checks likewise nests here (the dotcom docs omit it, but the GHEC flavor documents it and GitHub's validity-checks page points at this endpoint). | | [Topics](https://docs.github.com/en/rest/repos/repos) | `repository (topics key)` | PUT /repos/{owner}/{repo}/topics. Only normalization: string-to-list splitting (normalizeTopics) and order-insensitive compare in check mode. | | [Dependabot alerts](https://docs.github.com/en/rest/repos/repos) (vulnerability alerts) | `repository (enable_vulnerability_alerts)` | PUT/DELETE /repos/{owner}/{repo}/vulnerability-alerts; check mode probes GET and treats 404 as disabled. GitHub couples the dependency graph to this endpoint: the PUT/DELETE also enable/disable the graph as a side effect (the graph toggle itself has no API of its own; see No public API). | diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index f239dc42..c2156271 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -121,6 +121,10 @@ A snapshot is written so that applying it changes nothing and checking it reads | `webhooks[].config.secret` | GitHub echoes `********` | A `$WEBHOOK_SECRET_N` reference and a note per hook | | `interaction_limits.expiry` | GitHub reports only the computed `expires_at` | No `expiry` key; apply re-arms the limit with GitHub's default unless you declare one | | `check_suite_preferences` | GitHub exposes no read endpoint | Nothing; the header says so | +| `repository.name` | The repository's identity: a reused file would rename its target | Nothing | +| `repository.` GitHub reports as null | No declarable value | Nothing for that field | +| `repository.security_and_analysis.` the PATCH does not accept | Read-only state (dependabot_security_updates is `enable_automated_security_fixes`' own) | Only the PATCHable sub-keys | +| `repository.enable_immutable_releases` under owner enforcement | GitHub answers 409 to both writes | `true`, with a header line saying apply cannot change it from the repository | | `repository.enable_git_lfs` | GitHub exposes no read endpoint | Nothing; the header says so, and apply re-asserts the declared value on every run | | `repository.enable_*` toggles under a token whose every toggle probe answers 404 | A fine-grained token missing the Administration grant is answered like a disabled toggle | Nothing for the four toggles, under one header line | | `actions.` the token cannot read | A sub-endpoint has its own grant (the OIDC template needs Actions) | Nothing for that key; the header names it, and the other keys read back | diff --git a/src/sections/repository/repository.docs.yml b/src/sections/repository/repository.docs.yml index 5466d6b0..598495c0 100644 --- a/src/sections/repository/repository.docs.yml +++ b/src/sections/repository/repository.docs.yml @@ -18,15 +18,10 @@ coverage: PATCH /repos/{owner}/{repo} verbatim passthrough (src/sections/repository/index.ts). Any field GitHub adds to the repo PATCH body, now or in any release to come, works day one with no action update, the forward-compatibility tenet's strongest case. Check mode = subsetDiff of - declared keys against GET /repos. Snapshot reads back every documented PATCH field the GET - reports except `name` (the repository's identity; a reused file would rename its target) and - any null field, plus has_discussions; inside security_and_analysis only the sub-keys the PATCH - accepts (dependabot_security_updates is enable_automated_security_fixes' own state); the GET's server fields (ids, urls, counts, - has_downloads) are not PATCHable and fall away. Topics are read back when any are set, each - readable toggle as GitHub reports it (an owner-enforced immutable-releases toggle is noted as - immovable; when every toggle GET answers 404 the four are left out under a note, since a - fine-grained token missing the grant is answered the same way), and the two GraphQL keys; Git LFS has no read and is noted, and a toggle or - GraphQL read the token is denied is noted and left out. + declared keys against GET /repos. Snapshot reads back the documented PATCH fields the GET + reports, the topics, each readable toggle, and the two GraphQL keys; the server-only fields + (ids, urls, counts, has_downloads) fall away. The fields it leaves out or notes are listed in + docs/operate/snapshot.md. - area: >- [security_and_analysis](https://docs.github.com/en/rest/repos/repos) (GitHub Advanced Security, secret scanning, push protection, and any nested toggle GitHub adds) diff --git a/src/sections/rulesets/index.ts b/src/sections/rulesets/index.ts index 37dd2623..d0f40c86 100644 --- a/src/sections/rulesets/index.ts +++ b/src/sections/rulesets/index.ts @@ -70,11 +70,11 @@ type LiveRulesetSummary = z.infer; const permission: SectionPermission = { repo: ["administration"] }; /** - * A summary this section may manage: anything not explicitly owned by another source (source_type - * is optional in the API type). Deletion is gated harder in plan(): only an explicit "Repository" - * is ever deleted, since a missing field is not proof. + * A summary this section may manage: one no other source explicitly owns (source_type is optional in + * the API type). Deletion is gated harder in plan(): only an explicit "Repository" is ever deleted, + * since a missing field is not proof. */ -function ownedByRepository(summary: LiveRulesetSummary): boolean { +function notInherited(summary: LiveRulesetSummary): boolean { return (summary.source_type ?? "Repository") === "Repository"; } @@ -163,7 +163,7 @@ export const rulesetsSection = { z.array(LiveRulesetSummary), await ctx.read.list.listAll(), ); - const repoRulesets = summaries.filter(ownedByRepository); + const repoRulesets = summaries.filter(notInherited); const idByName = new Map(repoRulesets.map((r) => [r.name, r.id])); const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; @@ -253,22 +253,22 @@ export const rulesetsSection = { await ctx.read.list.listAll(), ); const notes = summaries - .filter((summary) => !ownedByRepository(summary)) + .filter((summary) => !notInherited(summary)) .map( (summary) => `rulesets[${summary.name}]: inherited from the ${String(summary.source_type).toLowerCase()} (source_type "${summary.source_type}"), so it is not part of the repository's snapshot; manage it where it is defined`, ); - const owned = summaries.filter(ownedByRepository); + const manageable = summaries.filter(notInherited); // plan() upserts by name, so two live rulesets under one name have no declarable form. rejectLiveDuplicates( this, "ruleset", - owned, + manageable, (summary) => summary.name, (summary) => `${summary.name} (id ${summary.id})`, ); const entries: RulesetConfig[] = []; - for (const summary of owned) { + for (const summary of manageable) { const live = await ctx.read.get.call({ params: { ruleset_id: String(summary.id) } }); if (!bypassActorsVisible(live)) { notes.push( @@ -278,9 +278,9 @@ export const rulesetsSection = { } entries.push(projectOntoSchema(RulesetConfig, live)); } - // Owned rulesets that were all left out still exist: an empty keep wrapper says so, where + // Manageable rulesets that were all left out still exist: an empty keep wrapper says so, where // `undefined` would render as "nothing exists on the repository". - if (owned.length === 0) { + if (manageable.length === 0) { return { value: undefined, notes }; } return { value: knobbedSnapshot(this, entries), notes }; From 857c45694148218427e708bbf8465d2e0e218101 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:29:05 -0400 Subject: [PATCH 5/7] style(snapshot): wrap the note literals over the fleet's 256-character cap --- src/sections/repository/index.ts | 4 +++- src/sections/repository/repository.test.ts | 4 +++- src/sections/rulesets/rulesets.test.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/sections/repository/index.ts b/src/sections/repository/index.ts index 455d85ce..377472f6 100644 --- a/src/sections/repository/index.ts +++ b/src/sections/repository/index.ts @@ -697,7 +697,9 @@ export const repositorySection = { // nothing, so the toggles are left out rather than written as off. if (probes.length > 0 && probes.every((probe) => probe.concealable)) { notes.push( - `repository.${probes.map((probe) => probe.toggle.key).join("/")}: every toggle GET answered 404, which reads as off but is also how a fine-grained token missing the grant is answered, so they are left out; if the token does ${sectionGrant(this)}, they are all off and can be declared false`, + `repository.${probes.map((probe) => probe.toggle.key).join("/")}: every toggle GET answered 404, which reads as off ` + + "but is also how a fine-grained token missing the grant is answered, so they are left out; " + + `if the token does ${sectionGrant(this)}, they are all off and can be declared false`, ); } else { for (const { toggle, live } of probes) { diff --git a/src/sections/repository/repository.test.ts b/src/sections/repository/repository.test.ts index c7acd716..822f6cb3 100644 --- a/src/sections/repository/repository.test.ts +++ b/src/sections/repository/repository.test.ts @@ -914,7 +914,9 @@ describe("repository snapshot", () => { expect(await snapshot(allOff)).toEqual({ value: { enable_sponsorships: false, issue_creation_policy: "all" }, notes: [ - `repository.enable_vulnerability_alerts/enable_automated_security_fixes/enable_private_vulnerability_reporting/enable_immutable_releases: every toggle GET answered 404, which reads as off but is also how a fine-grained token missing the grant is answered, so they are left out; if the token does ${sectionGrant(repositorySection)}, they are all off and can be declared false`, + "repository.enable_vulnerability_alerts/enable_automated_security_fixes/enable_private_vulnerability_reporting/enable_immutable_releases: " + + "every toggle GET answered 404, which reads as off but is also how a fine-grained token missing the grant is answered, so they are left out; " + + `if the token does ${sectionGrant(repositorySection)}, they are all off and can be declared false`, LFS_NOTE, ], }); diff --git a/src/sections/rulesets/rulesets.test.ts b/src/sections/rulesets/rulesets.test.ts index 8238a05e..f3c9e7db 100644 --- a/src/sections/rulesets/rulesets.test.ts +++ b/src/sections/rulesets/rulesets.test.ts @@ -612,7 +612,9 @@ describe("rulesets snapshot", () => { }, notes: [ 'rulesets[org-wide]: inherited from the organization (source_type "Organization"), so it is not part of the repository\'s snapshot; manage it where it is defined', - "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); grant Administration write to read it back", + "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), " + + "and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); " + + "grant Administration write to read it back", ], }); expect(api.writes).toEqual([]); @@ -634,7 +636,9 @@ describe("rulesets snapshot", () => { expect(read).toEqual({ value: { _undeclared: "keep", entries: [] }, notes: [ - "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); grant Administration write to read it back", + "rulesets[tags]: bypass_actors is not visible to this token (GitHub returns it only to a token with write access to the ruleset), " + + "and an entry without it would clear the bypass list on the next update, so the ruleset is left out of the snapshot (kept undeclared); " + + "grant Administration write to read it back", ], }); const planned = await rulesetsSection.plan( From 902f25853e17e9242eec951ce25d8c79072608be Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:53:13 -0400 Subject: [PATCH 6/7] test(rulesets): snapshot under a read-grade token writes the empty keep wrapper end to end --- src/sections/actions/index.ts | 1 - src/sections/repository/index.ts | 1 - .../rulesets-snapshot-bypass-hidden.yml | 65 +++++++++++++++++++ test/schema-corpus.test.ts | 2 +- 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/sections/rulesets/scenarios/rulesets-snapshot-bypass-hidden.yml diff --git a/src/sections/actions/index.ts b/src/sections/actions/index.ts index d6c86217..1617cea1 100644 --- a/src/sections/actions/index.ts +++ b/src/sections/actions/index.ts @@ -246,7 +246,6 @@ export function endpointRouted( }; } -/** The live body of one routed key projected onto its slice of the section shape. */ function sliceOf(key: K): (live: unknown) => ActionsConfig[K] { const slice: z.ZodType = ActionsConfig.shape[key]; return (live) => projectOntoSchema(slice, live) as ActionsConfig[K]; diff --git a/src/sections/repository/index.ts b/src/sections/repository/index.ts index 377472f6..d7f8f33d 100644 --- a/src/sections/repository/index.ts +++ b/src/sections/repository/index.ts @@ -181,7 +181,6 @@ type _SnapshotPatchFieldsComplete = MustBeNever< Exclude >; -/** The nested security_and_analysis object as the PATCH body accepts it. */ type SecurityAndAnalysisPatch = NonNullable; /** A nested key the API accepts (see the coverage notes) that the descriptor's PATCH body omits. */ diff --git a/src/sections/rulesets/scenarios/rulesets-snapshot-bypass-hidden.yml b/src/sections/rulesets/scenarios/rulesets-snapshot-bypass-hidden.yml new file mode 100644 index 00000000..80adf871 --- /dev/null +++ b/src/sections/rulesets/scenarios/rulesets-snapshot-bypass-hidden.yml @@ -0,0 +1,65 @@ +name: rulesets-snapshot-bypass-hidden +description: "mode: snapshot under a read-grade token, whose ruleset GETs omit bypass_actors. An entry without that key would clear the bypass list on the next full-payload update, so every repository ruleset is left out with a note and the section writes the empty keep wrapper, never a false 'nothing exists'; the organization's inherited ruleset is a note too. The written file, fed back to a check run against the same state, reads clean without a write." +settings: {} +live_state: + rulesets: + - id: 41 + name: build + source_type: Repository + target: branch + enforcement: active + conditions: + ref_name: + include: ["refs/heads/build"] + exclude: [] + rules: + - type: deletion + bypass_actors: [] + - id: 42 + name: protect-main + source_type: Repository + target: branch + enforcement: active + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + rules: + - type: deletion + bypass_actors: + - actor_id: 5 + actor_type: RepositoryRole + bypass_mode: always + - id: 900 + name: org-baseline + source_type: Organization + target: branch + enforcement: active + rules: + - type: non_fast_forward +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: rulesets +token_permissions: + administration: read +expect: + exit_code: 0 + result: snapshot + outcomes: + rulesets: snapshot + snapshot: + rulesets: + _undeclared: keep + entries: [] + stdout_contains: + - "rulesets[org-baseline]: inherited from the organization" + - "rulesets[build]: bypass_actors is not visible to this token" + - "rulesets[protect-main]: bypass_actors is not visible to this token" + - "so the ruleset is left out of the snapshot (kept undeclared)" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/test/schema-corpus.test.ts b/test/schema-corpus.test.ts index 8530246a..8ab64bb5 100644 --- a/test/schema-corpus.test.ts +++ b/test/schema-corpus.test.ts @@ -66,7 +66,7 @@ function scenarioDocs(): CorpusDoc[] { } } // The corpus size is pinned exactly so a loader that silently drops a root, a file, or a document kind cannot pass. - expect(docs.length).toBe(315); + expect(docs.length).toBe(317); return docs; } From d5c7d7fc251f2d42aff08131c6dfc7e4171a435d Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:27:35 -0400 Subject: [PATCH 7/7] test(corpus): re-pin after #147 and #148 landed, keep the inputs row out of the exception table, and order the allowlist outcomes by registry --- docs/operate/snapshot.md | 1 - test/engine/snapshot.test.ts | 6 ++++-- test/schema-corpus.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index c2156271..17d74f1d 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -130,7 +130,6 @@ A snapshot is written so that applying it changes nothing and checking it reads | `actions.` the token cannot read | A sub-endpoint has its own grant (the OIDC template needs Actions) | Nothing for that key; the header names it, and the other keys read back | | `rulesets[]` whose `bypass_actors` the token cannot see | GitHub returns the list only to a write-grade token | No entry (kept under `_undeclared: keep`) and a header line; an entry without the list would clear it on the next update | | Organization and enterprise rulesets | Inherited, not the repository's to manage | Nothing; a header line names each | -| `repository` | With `snapshot-file`: the target, defaulting to the current repository. Rejected with `snapshot-dir` | | `collaborators`: the repository owner, email invitations | The owner's access is implicit, and an email invitation has no username to declare | No entry and a note each; apply leaves both alone | | `collaborators`: expired invitations | A declared one would be cancelled and re-sent; an undeclared one is cancelled under the delete default the file carries | No entry and a note per invitation; add the entry to re-invite | | A custom role named `push` or `pull` | In a settings file those words mean the `write` and `read` roles, so no declaration plans as the live role | `collaborators` fails and the file is written without it; `teams` omits the team with a note | diff --git a/test/engine/snapshot.test.ts b/test/engine/snapshot.test.ts index a0702cac..ba9fa419 100644 --- a/test/engine/snapshot.test.ts +++ b/test/engine/snapshot.test.ts @@ -197,14 +197,16 @@ describe("snapshotRepository", () => { api, { ...opts(), - sections: SectionSelection.of({ only: ["labels", "check_suite_preferences"] })._unsafeUnwrap(), + sections: SectionSelection.of({ + only: ["labels", "check_suite_preferences"], + })._unsafeUnwrap(), }, captureIo().io, ); expect(result.result).toBe("snapshot"); expect(result.outcomes.map((o) => [o.key, o.status])).toEqual([ - ["check_suite_preferences", "unsupported"], ["labels", "snapshot"], + ["check_suite_preferences", "unsupported"], ]); expect(Object.keys(result.settings ?? {})).toEqual(["labels"]); }); diff --git a/test/schema-corpus.test.ts b/test/schema-corpus.test.ts index 8ab64bb5..072e5ee0 100644 --- a/test/schema-corpus.test.ts +++ b/test/schema-corpus.test.ts @@ -66,7 +66,7 @@ function scenarioDocs(): CorpusDoc[] { } } // The corpus size is pinned exactly so a loader that silently drops a root, a file, or a document kind cannot pass. - expect(docs.length).toBe(317); + expect(docs.length).toBe(323); return docs; }