diff --git a/COVERAGE.md b/COVERAGE.md index c21e1b5a..598abe6b 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -19,7 +19,7 @@ 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. 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. | +| [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). The list carries summaries, so each matched ruleset is read whole before the comparison. 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 are outside the section (source_type other than Repository). 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 left out with a note, and so is a ruleset whose bypass_actors the token cannot see (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. The snapshot's own rules read (BranchProtectionRulesSnapshot, the planner's selection under a declaration that tolerates no denial, so a concealed 404 is a PermissionDenied naming the grant, which the engine turns into a skipped section under on-missing-permission: warn and a failed run under fail, instead of reading as no rules) supplies the GraphQL-only surface: a literal entry carries its force_push_bypassers (actor strings in the file's spelling, sorted) and required_deployments when set, an empty allowance list and an off requirement omitted since an absent routed key leaves the live value untouched; every wildcard rule is written as its own entry with the off controls dropped; a branch only a wildcard rule protects is NOT written as a literal entry (the REST read serves that rule's protection under the branch's name, and a literal entry would create a second rule on apply); a literal-pattern rule whose branch the REST read did not surface (no such branch, or a denied probe) is a note, since a literal entry applies through the PUT that needs the branch. | @@ -41,9 +41,9 @@ The tenet: this action can control everything about a repository and nothing abo | [Interaction limits](https://docs.github.com/en/rest/interactions/repos) | `interaction_limits` | GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits, plus the two routed keys below. Limits self-expire (expiry tops out at six_months), so apply re-arms the declared limit on EVERY run and check mode reports drift once it lapses - schedule apply more often than the chosen expiry to keep it armed. The declared expiry is write-only (GitHub reads back only the computed expires_at), so check verifies the limit value, not the duration. `interaction_limits: null` clears a live repo-level limit - the base limit only, never the cap or bypass list below. In a mode: merge fold, interaction_limits: null over a lower layer's declaration removes the key from the merged document instead, mirroring pages: null. A 409 means an organization- or user-level limit overrides the repository's; writes surface that as a note, not a failure, while check mode still reports a mismatched declaration as drift - the org is the place to change it. `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm. Where the cap is unavailable the endpoints answer 405, which apply surfaces as a note (like the 409) and check as honest drift. `pull_request_creation_bypass` routes to GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits/pulls/bypass-list and reconciles: apply DELETEs only the undeclared logins and then PUTs only the missing ones ({users: [logins]}, compared case-insensitively; removals go first because the list holds at most 100 users, which also caps the declaration), never a wholesale replace; a declared empty list removes everyone. Snapshot reads back the repository-level limit (an inherited one is noted, not declared, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. | | [Actions variables](https://docs.github.com/en/rest/actions/variables) | `actions_variables` | GET/POST /repos/{owner}/{repo}/actions/variables and PATCH/DELETE /repos/{owner}/{repo}/actions/variables/{name}: plain-text repository variables, upserted by name. Names are case-insensitive (GitHub stores them uppercased regardless of how they are entered), so matching and duplicate rejection compare uppercased names. Values read back in full, which is what makes exact check-mode diffing possible; undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. The list endpoint caps per_page at 30 (not the usual 100), which the page loop honors so a large inventory is never truncated. Secrets are write-only material and deliberately NOT this section: they live in the `actions_secrets` section above. Snapshot reads the variables back as name and value entries under the delete default. | | [Copilot agents variables](https://docs.github.com/en/rest/agents/variables) | `agents_variables` | GET/POST /repos/{owner}/{repo}/agents/variables and PATCH/DELETE /repos/{owner}/{repo}/agents/variables/{name}: same upsert-by-uppercase-name semantics, exact check-mode diffing, and 30-item per_page cap as `actions_variables`, over the Copilot agents variable store. (GET .../agents/organization-variables is the read-only org-inherited view, out of scope.) Undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. CAVEAT: GitHub auto-migrated values from the older `copilot` Actions environment into this store, so a repository can hold agents variables nobody created here - run `mode: check` first (it lists them as deletion drift) before the first apply, or declare `_undeclared: keep`. Snapshot reads the variables back as name and value entries under the delete default. | -| [Webhooks](https://docs.github.com/en/rest/repos/webhooks) | `webhooks` | GET/POST /repos/{owner}/{repo}/hooks, PATCH/DELETE .../hooks/{hook_id}, and PATCH .../hooks/{hook_id}/config. At most ONE hook per config.url (the natural key); a declared url matching several live hooks fails loudly naming their ids, and a changed url is a NEW hook (the old one turns _undeclared: kept and noted by default, deleted under `_undeclared: delete`). Config-field drift goes through the config sub-endpoint, which updates named fields without the general PATCH's whole-config replacement, so a live secret the file does not declare is never removed; events/active drift rides the general PATCH with no config key. config.secret is declared as a whole-value `$NAME` reference resolved from the step env at apply time ([secrets and vaults](docs/reference/secrets-and-vaults.md)); GitHub echoes a live secret as "********", so check mode verifies everything except the secret (a cannot-verify note) and apply re-sends the declared secret every run so rotations propagate. insecure_ssl compares number and string spellings as equal (GitHub stores strings); events compare order-insensitively. Hook urls appear in drift and change lines on purpose: they are configuration, not credentials (the secret is the masked part). Snapshot reads the hooks back as entries under the keep default, a set secret as a `$WEBHOOK_SECRET_` reference with a note to export it. | +| [Webhooks](https://docs.github.com/en/rest/repos/webhooks) | `webhooks` | GET/POST /repos/{owner}/{repo}/hooks, PATCH/DELETE .../hooks/{hook_id}, and PATCH .../hooks/{hook_id}/config. At most ONE web hook per config.url (the natural key); two live hooks under one url fail loudly, and a changed url is a NEW hook (the old one turns _undeclared: kept and noted by default, deleted under `_undeclared: delete`). A legacy service hook (name other than "web") or a hook without a config.url is outside the section: plan neither matches nor deletes it, snapshot notes it. Config-field drift goes through the config sub-endpoint, which updates named fields without the general PATCH's whole-config replacement, so a live secret the file does not declare is never removed; events/active drift rides the general PATCH with no config key. config.secret is declared as a whole-value `$NAME` reference resolved from the step env at apply time ([secrets and vaults](docs/reference/secrets-and-vaults.md)); GitHub echoes a live secret as "********", so check mode verifies everything except the secret (a cannot-verify note) and apply re-sends the declared secret every run so rotations propagate. insecure_ssl compares number and string spellings as equal (GitHub stores strings); events compare as a set. Hook urls appear in drift and change lines on purpose: they are configuration, not credentials (the secret is the masked part). Snapshot reads the hooks back as entries under the keep default, a set secret as a `$SECRET_WEBHOOK_` reference with a note to export it. | | [Custom property values](https://docs.github.com/en/rest/repos/custom-properties) | `custom_properties` | GET /repos/{owner}/{repo}/properties/values (unpaginated) and ONE bulk PATCH on the same path carrying every divergent declared property, skipped entirely when nothing diverges. Values of org-defined properties only: property DEFINITIONS are /orgs/-scoped and not managed here, so the section probes GET /orgs/{owner} and no-ops with a note on personal accounts (404 only; 403/5xx still fail), mirroring `teams`. `value: null` unsets a property (reverting to the org default, if any); booleans/numbers normalize to their string form (GitHub transports true_false values as the strings "true"/"false"); multi_select lists compare order-insensitively. The values READ is gated by Metadata only (every fine-grained token holds it), so only the PATCH needs the "Custom properties" grant; a 403 on it can also mean the org restricts a property's values to org actors (values_editable_by: org_actors), and a 422 means the property is not defined at the organization level. Undeclared live values are KEPT by default (an unset can revert to an org default the action does not model); the wrapped `_undeclared: delete` form opts into unsetting them. Snapshot reads the set values back as entries under the keep default; unset (null) values are omitted, and a personal account yields nothing with a note. | -| [Deploy keys](https://docs.github.com/en/rest/deploy-keys/deploy-keys) | `deploy_keys` | GET/POST /repos/{owner}/{repo}/keys and DELETE /repos/{owner}/{repo}/keys/{key_id}, matched by exact title (a declared title matching several live keys fails loudly naming their ids - GitHub does not enforce title uniqueness). The declared material is a PUBLIC key, so it is safe to keep in settings.yml (unlike secrets). Keys are immutable upstream (no update endpoint exists), so a changed key or declared read_only is applied as delete + recreate, the autolinks pattern (an UNDECLARED read_only survives the recreate: the live value is re-sent, so a rotation never widens access). Renaming a key while keeping its material cannot be applied - the one-repository-per-key rule makes the create collide with the live key - so the section fails loudly naming the live holder; delete or rename the live key on GitHub first. Material is compared as algorithm + base64 blob with the trailing comment ignored, because GitHub may strip or rewrite comments on storage - a raw-string compare would replace the key on every apply. A 422 on create can mean the public key is already attached to another repository (one repository per key, account-wide); the error says so. Undeclared keys are KEPT by default (deleting a live deploy key breaks whatever service authenticates with it, and deployment tooling installs its own keys); the wrapped `_undeclared: delete` form opts into deletion. Snapshot reads the live keys back (title, comparable material, read_only) under the keep default. | +| [Deploy keys](https://docs.github.com/en/rest/deploy-keys/deploy-keys) | `deploy_keys` | GET/POST /repos/{owner}/{repo}/keys and DELETE /repos/{owner}/{repo}/keys/{key_id}, matched by exact title (two live keys under one title fail loudly, since GitHub does not enforce title uniqueness). The declared material is a PUBLIC key, so it is safe to keep in settings.yml (unlike secrets). Keys are immutable upstream (no update endpoint exists), so a changed key or declared read_only is applied as delete + recreate, the autolinks pattern (an UNDECLARED read_only survives the recreate: the live value is re-sent, so a rotation never widens access). Renaming a key while keeping its material cannot be applied - the one-repository-per-key rule makes the create collide with the live key - so the section fails loudly naming the live holder; delete or rename the live key on GitHub first. Material is compared as algorithm + base64 blob with the trailing comment ignored, because GitHub may strip or rewrite comments on storage - a raw-string compare would replace the key on every apply. A 422 on create can mean the public key is already attached to another repository (one repository per key, account-wide); the error says so. Undeclared keys are KEPT by default (deleting a live deploy key breaks whatever service authenticates with it, and deployment tooling installs its own keys); the wrapped `_undeclared: delete` form opts into deletion. Snapshot reads the live keys back (title, comparable material, read_only) under the keep default. | | [Secret scanning custom patterns](https://docs.github.com/en/rest/secret-scanning/custom-patterns) (repository-level) | `secret_scanning_custom_patterns` | GET /repos/{owner}/{repo}/secret-scanning/custom-patterns (paginated), bulk POST on the same path ({patterns: [...]}), PATCH .../custom-patterns/{pattern_id}, and bulk DELETE on the collection path (ids in the request body). Patterns are matched by name; the name is immutable upstream (the PATCH takes no name field), so a rename creates the new name and the old pattern follows the undeclared policy - deleted under `_undeclared: delete`, kept and noted under the default keep. The declarable surface is name, pattern, start_delimiter, end_delimiter, and the must_match/must_not_match regex lists - `state` and `push_protection_enabled` are READABLE in the GET but NOT writable through these endpoints (no field in the POST/PATCH bodies, no sub-endpoint), so they cannot be declared and the validator says so. CAVEAT (state is invisible to drift): an UNPUBLISHED live pattern whose fields match the declaration reads as converged even though it is not enforcing - no publish endpoint exists, so publishing stays a UI action, and that includes a pattern this action just created - GitHub does not document the state a bulk-created pattern lands in, so verify the first apply in the UI. A delimiter, once set, cannot be cleared back to GitHub's default through the PATCH (it updates provided fields only); delete and redeclare the pattern without the field instead. Writes ride optimistic concurrency whenever GitHub supplies a version: the list GET carries each pattern's custom_pattern_version, the PATCH/DELETE send it back, and a 412 means the pattern changed mid-run (re-run the workflow); a version-less pattern (the field is optional and nullable upstream) writes without the check, as the API allows. Undeclared patterns are KEPT by default (removing a pattern disposes of its alerts, so it stays a human opt-in; `_undeclared: delete` opts in), and every delete this action issues sends post_delete_action: "resolve_alerts" - never upstream's alert-deleting default - so the audit trail survives. Needs secret scanning enabled (GitHub Advanced Security on private repositories); a 404 can mean that rather than a missing grant. Snapshot reads the live patterns back (name, pattern, delimiters, must_match lists) under the keep default. | ## Repo-scoped gaps (not built yet) diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index b3bc899a..af9803e9 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -74,7 +74,7 @@ GitHub never returns a secret's value, so the file cannot hold one. The snapshot |---|---|---| | A repository Actions secret named `DEPLOY_TOKEN` | `value: $SECRET_ACTIONS_DEPLOY_TOKEN` | `SECRET_ACTIONS_DEPLOY_TOKEN` | | The same name in another store (`dependabot_secrets`, `codespaces_secrets`, `agents_secrets`) | `$SECRET_DEPENDABOT_DEPLOY_TOKEN`, `$SECRET_CODESPACES_DEPLOY_TOKEN`, `$SECRET_AGENTS_DEPLOY_TOKEN` | One variable per store, so two stores holding the same name never share a value by accident | -| A webhook's `config.secret`, one per hook | `secret: $WEBHOOK_SECRET_601` | `WEBHOOK_SECRET_`, the hook's own id, so the reference survives a reordering | +| A webhook's `config.secret`, one per hook | `secret: $SECRET_WEBHOOK_601` | `SECRET_WEBHOOK_`, the hook's own id, so the reference survives a reordering | | An environment secret named `DEPLOY_TOKEN` in the `production` environment | `value: $SECRET_ENVIRONMENT_PRODUCTION_DEPLOY_TOKEN` | `SECRET_ENVIRONMENT__`, the environment name uppercased with every other character folded to `_`, so same-named secrets in two environments get two variables; two names the fold collapses (`prod-eu`, `prod_eu`) share one, and a header line names the entries to edit | Wire them the way the [secrets guide](../reference/secrets-and-vaults.md) describes: an `env:` block on the apply step, fed from GitHub Secrets or a vault action. A reference that is not exported fails the run that reads the file, naming the variable. @@ -122,7 +122,7 @@ A snapshot is written so that applying it changes nothing and checking it reads | Exception | Why | What the file holds | |---|---|---| | Secret values | GitHub returns names only | A `$NAME` reference and a note per secret | -| `webhooks[].config.secret` | GitHub echoes `********` | A `$WEBHOOK_SECRET_N` reference and a note per hook | +| `webhooks[].config.secret` | GitHub echoes `********` | A `$SECRET_WEBHOOK_` 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 | diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index e12c77d3..c4cff686 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -4,7 +4,7 @@ order: 20 # Upgrading from v2 to v3 -Ten breaks (the ninth is for library consumers). One is silent (the fallback), so run `mode: check` before the first v3 apply. The changelog entry for 3.0.0 will carry the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md). +Fifteen breaks (the ninth is for library consumers). One is silent (the fallback), so run `mode: check` before the first v3 apply. The changelog entry for 3.0.0 will carry the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md). | Break | v2 | v3 | What the old form does now | |---|---|---|---| @@ -18,6 +18,11 @@ Ten breaks (the ninth is for library consumers). One is silent (the fallback), s | A snapshot section that fails on its own fails the target | `result: partial`, exit 0, the file written without that section | `result: failed`, exit 1, no file for that target; `init` refuses to write | The workflow step fails where it passed; [section 8](#8-a-failed-snapshot-section-fails-the-target) | | Library: a documented public entry, an internal one, and the v3 names | One entry, 192 names; `GithubApi`, `RepoRunReport`, `validateSettings` returning `warnings` | `.` holds the 118 documented names, `./internal` the rest; `GitHubApi`, `CheckReport`, every report carries `log` | The old import fails to compile, naming the missing export; every rename is in [section 9](#9-library-the-public-entry-and-the-v3-names) | | One redacted label in every mode | A single-repository run labelled its hidden target `private repository`; a fleet numbered them `private repository #N` | `private repository #N` everywhere; a run over one repository is `#1` | No error. A log filter or artifact-report reader matching `private repository:` exactly no longer matches; [section 10](#10-one-redacted-label-in-every-mode) | +| Two live items under one identity fail the section | The last one listed won silently in most sections | Every list section refuses, naming the pair | The section fails until one is deleted on GitHub; [section 11](#11-two-live-items-under-one-identity-fail-the-section) | +| The webhook snapshot placeholder | `$WEBHOOK_SECRET_` | `$SECRET_WEBHOOK_` | No error: an old reference keeps resolving from its old export; a new snapshot writes the new name, so move both together; [section 12](#12-the-webhook-snapshot-placeholder-leads-with-secret_) | +| One wording per concept in drift lines and notes | Per-section spellings of "cannot verify", "left out", and field drift; quoted webhook labels | One template each on the converted sites | Only a grep over the output notices; [section 13](#13-one-wording-per-concept-in-drift-lines-and-notes) | +| Webhooks manage web hooks only | A service hook was matched and deleted like any other | A service hook or url-less hook is outside the section | It is left alone and noted by snapshot; [section 14](#14-webhooks-manage-web-hooks-only) | +| A ruleset without `source_type` is repository-owned | Kept with a note under `_undeclared: delete` | Deleted like any other undeclared repository ruleset | [section 15](#15-a-ruleset-without-source_type-is-repository-owned) | ## 1. The defaults-file fallback @@ -184,10 +189,63 @@ The `artifact` report channel heads that run's report `