From eb32bf32e2fa9db69361a2613c9637bc25cc03e5 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:52:21 -0400 Subject: [PATCH 1/4] docs(upgrading): split the v2-to-v3 guide by area under an index page The guide sat at 1298 lines, two under the fleet's 1300-line cap on a markdown file, so the next break section could not land. Every numbered section moves verbatim to one of eight area pages under docs/upgrading/v2-to-v3/, keeping its number, title, and anchor; the index page keeps the intro, the summary table grouped by area, and the order of operations. Links that now cross pages gain their page. Two corrections ride along because the index is rewritten: the intro count reads sixty-one, and the summary table gains the row for section 61, which the base commit added without one. The one test that read the guide by path now reads the page that holds section 2. --- docs/upgrading/v2-to-v3.md | 1352 ++--------------- docs/upgrading/v2-to-v3/layering.md | 253 +++ docs/upgrading/v2-to-v3/library.md | 192 +++ docs/upgrading/v2-to-v3/refusals-lists.md | 266 ++++ .../upgrading/v2-to-v3/refusals-repository.md | 125 ++ docs/upgrading/v2-to-v3/releases.md | 37 + docs/upgrading/v2-to-v3/run.md | 219 +++ docs/upgrading/v2-to-v3/sections.md | 76 + docs/upgrading/v2-to-v3/settings-file.md | 107 ++ test/docs/guides.test.ts | 2 +- 10 files changed, 1374 insertions(+), 1255 deletions(-) create mode 100644 docs/upgrading/v2-to-v3/layering.md create mode 100644 docs/upgrading/v2-to-v3/library.md create mode 100644 docs/upgrading/v2-to-v3/refusals-lists.md create mode 100644 docs/upgrading/v2-to-v3/refusals-repository.md create mode 100644 docs/upgrading/v2-to-v3/releases.md create mode 100644 docs/upgrading/v2-to-v3/run.md create mode 100644 docs/upgrading/v2-to-v3/sections.md create mode 100644 docs/upgrading/v2-to-v3/settings-file.md diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 46cdd39f8..14d84ed1e 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -4,1288 +4,132 @@ order: 20 # Upgrading from v2 to v3 -Sixty breaks. Ten are for library consumers (sections 9, 23, 24, 27, 34, 35, 54, 56, 58, and 60), and two are for anyone pinning a sha or installing `@next` (sections 21 and 59). +Sixty-one breaks. Ten are for library consumers (sections 9, 23, 24, 27, 34, 35, 54, 56, 58, and 60), and two are for anyone pinning a sha or installing `@next` (sections 21 and 59). Eighteen are parse-time refusals (sections 36 to 52 and 55): a declaration GitHub would reject, or that could never converge, now fails before any request. Sections 53 and 57 are silent: YAML merge keys resolve, and one validation message is respelled. -The library sections describe the public entry, `@vivswan/github-settings-as-code`. The internal entry, `@vivswan/github-settings-as-code/internal`, carries no contract ([the two entries](../reference/library.md#the-two-entries)), so a change to a name that lives only there is not a break and this guide does not list it as a break. A name leaving the public entry for the internal one is a break, listed in [section 9](#9-library-the-public-entry-and-the-v3-names). +The library sections describe the public entry, `@vivswan/github-settings-as-code`. The internal entry, `@vivswan/github-settings-as-code/internal`, carries no contract ([the two entries](../reference/library.md#the-two-entries)), so a change to a name that lives only there is not a break and this guide does not list it as a break. A name leaving the public entry for the internal one is a break, listed in [section 9](v2-to-v3/library.md#9-library-the-public-entry-and-the-v3-names). The silent ones include the fallback, the renamed `GSAC_RETRY_BASE_MS`, the rendered file (it reorders once), and the snapshot file (it reorders once and no longer dates itself). Run `mode: check` before the first v3 apply and diff the first v3 rendered file. 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 | -|---|---|---|---| -| `defaults-file` is a fallback | Merged under every multi-repo target; a target without a settings file was skipped | Applied whole to a target that has no settings file; a target with its own file is applied as written, never merged | No error. A target file written as a partial overlay now runs alone, and `pages: null` in it disables Pages instead of opting out of the defaults; the check-first steps are under [section 1](#1-the-defaults-file-fallback) | -| The wrapper key is `_undeclared` | `labels: {undeclared: keep, entries: [...]}` | `labels: {_undeclared: keep, entries: [...]}` | Validation fails before any section runs, naming the rename; the full error is under [section 2](#2-undeclared-becomes-_undeclared) | -| Layering happens in `mode: render` | The only merge was the defaults-file one | An ordered list of files folds in a render step; apply and check take one final document | No error. Reach for the [two-step workflow](../operate/layering.md#the-two-step-workflow) to get merging back | -| A `settings-file` path cannot contain a comma or newline | `settings-file: settings,prod.yml` named one file | A comma or newline is a list separator in every mode | Apply and check refuse the run before reading anything (the error is under [section 4](#4-commas-and-newlines-in-a-settings-file-path)); `mode: render` reads two layer paths | -| `repos-result` rows spell `skipped-sections` | `{"o/r": {"result": "applied", "source": "central", "skippedSections": []}}` | `{"o/r": {"result": "applied", "source": "central", "skipped-sections": []}}` | No error. A step reading `.skippedSections` gets `null`; [section 5](#5-repos-result-rows-spell-skipped-sections) | -| The three outputs are always set | `repos-result` existed only in multi-repo runs and the `snapshot-dir` form; unset elsewhere | `result`, `skipped-sections`, and `repos-result` are set on every run; `repos-result` is `{}` outside a fleet | No error. A step testing `repos-result != ''` to detect a fleet run now always passes; [section 6](#6-the-three-outputs-are-always-set) | -| One `--json` envelope on the command line | `validate` printed `{file, valid, sections}`, `permissions` a bare grant map, `init` `skippedSections` | Every subcommand prints `{result, ...}`; `permissions` puts its grants under `grant`, lists are lists | A `jq` filter on the old keys reads `null`; [section 7](#7-one---json-envelope) | -| 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 | The pre-release v3 builds had one entry, 192 names; `GithubApi`, `RepoRunReport`, `validateSettings` returning `warnings` | the public entry holds the names the library page documents, the internal entry 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 live list 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 | 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 | No error: under `_undeclared: delete` the ruleset is deleted, where v2 kept it with a note; [section 15](#15-a-ruleset-without-source_type-is-repository-owned) | -| Underscore keys are directives, never notes | An unknown `_note: ...` at the top level was dropped silently | Three directives exist, each at its own place: `_layering` at the top level or on a list section's wrapper, `_undeclared` at the top level or on a knobbed wrapper, `_remove: true` on a keyed list entry only, in a higher layer of a fold (a single document refuses it); any other underscore key at the top level or on a wrapper, or a directive out of its place, fails validation | Validation fails before any section runs, naming the directives; [section 16](#16-underscore-keys-are-directives-never-notes) | -| `teams` takes the `_undeclared` knob | A plain array; an undeclared team was never listed or touched | `teams: {_undeclared: keep, entries: [...]}` accepted, default `keep`; `delete` revokes undeclared direct grants | No error. Every run now lists the repository's teams and notes each undeclared direct grant (access granted at the organization level is noted only under `delete`); snapshots write the wrapper form; [section 17](#17-teams-takes-the-_undeclared-knob) | -| `GSAC_RETRY_BASE_MS` | `RETRY_BASE_MS`, undocumented | `GSAC_RETRY_BASE_MS`, in the inputs reference | No error: an unknown environment variable is ignored, so a harness setting the old name waits real seconds; [section 18](#18-gsac_retry_base_ms) | -| The sealing key is read at apply time | Check mode read `GET .../secrets/public-key` and failed on a malformed key | The first sealed PUT reads it at apply | Check mode issues one request fewer per secret family; a malformed key fails at apply; [section 19](#19-the-sealing-key-is-read-at-apply-time) | -| Environment secrets and variables plan through the shared engines | Their own wording | The engines' wording | Only a grep over the output notices; [section 20](#20-environment-secrets-and-variables-plan-through-the-shared-engines) | -| The `build` branch retires | Every green push appended a packaged commit to the `build` branch; `latest` and the release tags pointed into it | One packaged commit per `main` commit under the tag `build/.`, the ten newest kept; `latest`, `@v3`, and `vX.Y.Z` point at them; the branch was deleted on 2026-09-13 | A sha pin into the branch names a commit no ref keeps alive, so GitHub may collect it at any time; a pin taken from a build tag goes when ten newer commits have been packaged; [section 21](#21-the-build-branch-retires) says what to pin instead | -| One wording for every face | The command line reworded two remedies and refused `private-report: artifact` on its own; other remedies said "re-run the workflow" | One problem renderer; remedies name the input and its flag; `parseConfig` refuses the artifact channel for a face without an upload (`input-artifact-unsupported`) | No error. A step or script matching an error line by its old text stops matching; [section 22](#22-one-wording-for-every-face) | -| Library: one owner per refusal, one selection type | `parseConfig(read, env)`; `executeRun` returned a number and took `describe`; `artifact-uploader-missing`; `validateSettings` took a `Set` | `parseConfig(read, env, capabilities)`; `executeRun` returns `{exitCode, fatal?}`; `input-artifact-unsupported`; `SectionSelection` everywhere; the `central-file` role | The call fails to compile, naming the missing argument or member; [section 23](#23-library-one-owner-per-refusal-one-selection-type) | -| The rendered file is the fold in the canonical order | The pre-release v3 builds wrote the validated parse, keys in the schema's order | The fold rendered in the one canonical order (sections in execution order, keys as the schema declares them, the entries of every keyed list by identity; `branches`, `bypass_actors`, `reviewers`, and scalar lists as written), byte for byte what `mergeSettings` returns as `yaml` | No error: a committed rendered file reorders once; [section 24](#24-the-rendered-file-is-the-fold-in-the-canonical-order) | -| Every live read is parsed at the port | A body off the documented shape flowed into the comparison (a null Actions body read as drift on every key) | Every read fails loudly naming the endpoint and the field | The section fails with `returned a body outside the documented shape`; [section 25](#25-every-live-read-is-parsed-at-the-port) | -| The snapshot file is canonical and undated | The second header line read `# Snapshot of owner/name taken `; list entries sat in the order GitHub listed them | No dated line: the run's notice and the step summary say `snapshot taken `; the document renders in the canonical order the rendered file shares | No error: a committed snapshot reorders once and loses its dated second line; a script reading the instant from the file reads the run's notice instead; [section 26](#26-the-snapshot-file-is-canonical-and-undated) | -| Library: one problem code for a malformed document | The pre-release v3 builds had `settings-unknown-directives` and `settings-unknown-sections`, one standalone stop each | Both are lines of `settings-malformed-sections`, beside the section issues | A `switch` on `Problem.code` naming a deleted code fails to compile; [section 27](#27-library-one-problem-code-for-a-malformed-document) | -| `_layering` takes `replace`, `shallow`, or `deep`, and every list section layers by key | No layering; the pre-release v3 builds took `merge` or `replace`, and only `labels` and `rulesets` unioned | `deep` (default) unions by the section's key and merges a same-key pair field by field; `shallow` unions and swaps the pair; `replace` lets the higher list win; all sixteen knobbed sections union | `_layering: merge` and `layering: merge` fail as unknown values, naming the three; a fourteen-section overlay that relied on silent replacement now unions; [section 28](#28-_layering-takes-replace-shallow-or-deep) | -| `mode: merge` is `mode: render` | No render mode; the pre-release v3 builds spelled it `mode: merge`, `merged-file`, CLI `merge`, result `merged` | `mode: render`, `rendered-file`, CLI `render`, result `rendered`; five problem codes and four library flow exports carry the render spelling | `mode: merge` fails as an unsupported mode value; a `merged-file` input draws the runner's unknown-input warning; [section 29](#29-mode-merge-is-mode-render) | -| `environments`, `branches`, and `workflows` layer by key | A higher list replaced the lower one whole | Union by key under `deep`: environment names case-insensitively, branch names verbatim, workflow paths as GitHub lists them; each accepts a `{_layering, entries}` wrapper; an environment's nested lists union by their own keys | No error: a higher layer that meant to replace now unions; write `_layering: replace` on the wrapper; [section 30](#30-environments-branches-and-workflows-layer-by-key) | -| A higher layer's `null` wins and means empty on GitHub; `_remove: true` drops a keyed entry | In v2's defaults merge a whole-section `null` opted out of the defaults' section while a nested `null` survived as a value; the pre-release fold read a `null` as "delete the lower key" | `null` is written as the value, the empty or off state; a key with no empty state refuses it; a whole-section `null` only on `pages` and `interaction_limits`; `_remove: true` drops one lower entry and is consumed | Validation fails naming the legal values; a `null` written to mean "stop managing this" empties the setting on GitHub instead; [section 31](#31-null-wins-and-means-empty-and-_remove-drops-an-entry) | -| File-wide `_undeclared` and the run input `undeclared` | The policy lived on each section's wrapper only | A top-level `_undeclared: keep` or `delete` sets every knobbed section of the file; the `undeclared` input sets the run; a wrapper beats the file, the file beats the input, the input beats the section default | No error: a top-level `_undeclared: delete` that v2 dropped as a note now deletes undeclared entries on the first apply; a file that set every wrapper by hand keeps working; a file-wide `delete` also deletes undeclared deployment protection rules; [section 32](#32-file-wide-_undeclared-and-the-undeclared-input) | -| Apply refuses to write over a live value the file omits | Check compared declared keys only; the ruleset and environment PUTs wrote the whole object, so an omitted live value was deleted while check said clean | Check reports the omitted value as drift; apply refuses that entry's PUT and fails the section | The section fails with `not applied - the update would remove a live value the settings file omits`; declare the key, or declare it empty; [section 33](#33-apply-refuses-over-omitted-live-values) | -| Every file-only check runs before the first write | A duplicate in a section the `sections` input excluded never ran; one in a selected section threw mid-run after earlier sections wrote | Every section's file-only checks run at validation, selected or not, before any request | A file that applied with `sections: repository` beside a broken `labels` fails with exit 1 and zero requests; a custom list module built with the library implements `validate`; [section 34](#34-file-only-checks-run-before-the-first-write) | -| Library: `plan()` takes only validator-minted input | In the pre-release v3 builds `labels.plan(ctx, [{name: "bug"}, {name: "Bug"}])` compiled, so a caller could skip the file-only checks | The parameter is `ValidatedInput`, minted by `validateSettings`, `mergeSettings`, or `snapshotRepository` | A raw list fails to compile, naming `ValidatedBrand`; [section 35](#35-library-plan-takes-only-validator-minted-input) | -| Labels: color and description are checked at parse | `color: red` reached GitHub as a 422 and never converged; a 150-character description 422ed | `color` is six hex digits, the `#` optional; `description` is at most 100 characters | Validation fails naming the entry and the rule; [section 36](#36-labels-colors-and-descriptions) | -| Repository: GET-only keys, commit-message pairs, topics, and security sub-keys | `has_downloads: false` drifted on every run; an illegal squash pair or a bad topic 422ed at apply | Refused at parse: a GET-only key (`has_downloads`, `has_pages`, `custom_properties`, ids, urls, counts), an unknown `security_and_analysis` sub-key, a commit message without its title or in an illegal squash pair, a malformed or empty topic, more than 20 topics | Validation fails naming the key and the fix; [section 37](#37-repository-get-only-keys-commit-pairs-topics-and-security-sub-keys) | -| Pages: GET-only site fields and the source path | `custom_404: true` rode the PUT, was dropped, and drifted forever; `source.path: /src` 422ed | `url`, `html_url`, `status`, `custom_404`, `protected_domain_state`, `pending_domain_unverified_at`, and `https_certificate` are refused; `source.path` is `/` or `/docs` | Validation fails naming the key; [section 38](#38-pages-get-only-fields-and-the-source-path) | -| Actions: reported-only fields, a closed `selected_actions`, enums and bounds | `maximum_allowed_days: 400` was re-PUT on every run; a misspelled `selected_actions` key was re-PUT silently forever | `selected_actions_url`, `artifact_and_log_retention.maximum_allowed_days`, and `oidc_customization_sub.sub_claim_prefix` are refused; `selected_actions` is closed to its three keys and typed; `approval_policy` is an enum; claim keys match `^[A-Za-z0-9_]+$` and are unique; retention days and cache limits are positive integers; `include_claim_keys` beside `use_default: true` is refused; `sha_pinning_required` is a known boolean | Validation fails naming the key and the fix; [section 39](#39-actions-fields-that-could-never-converge) | -| Code scanning and code quality setup: GET-only keys, languages, the runner pair | `schedule: weekly` and a `runner_label` under `runner_type: standard` each drifted forever; `languages: [javascript]` never matched the pair GitHub reports | `schedule` and `updated_at` are refused; languages are spelled as the PATCH takes them (`javascript-typescript`, no `rust`); a string `runner_label` goes only with `runner_type: labeled`, which requires one | Validation fails naming the key and the spelling to write; a key the GET never echoes earns the never-converges note; [section 40](#40-setup-sections-get-only-keys-languages-and-the-runner-pair) | -| Branches: protection shapes the file alone shows wrong | A protection copied from GitHub's GET 422ed on its `{url, enabled}` wrappers, and its other GET-only keys (`url`, `enforcement_level`) drifted forever; `required_status_checks` without `strict` 422ed; `required_approving_review_count: 7` 422ed | Refused at parse: a GET-only key at any depth (`name`, `enabled`, `enforcement_level`, `url`, `*_url`); `required_status_checks` needs `strict` and `contexts` or `checks`; the count is 0 to 6; a `checks` item takes only `context` and `app_id`; a scalar where the two mappings go | Validation fails naming the key and the fix; a live all-empty `restrictions` holder the file omits is now drift and a loud PUT, not silence; [section 41](#41-branches-protection-shapes) | -| Collaborators and teams: permissions and team slugs | `permission: write` converged on an existing Write grant and 422ed on a new one; `teams[].name: Core Team` probed `/teams/Core%20Team`, 404ed, and check said "no access" | `permission` refuses `read`, `write`, a mis-cased standard permission, an empty value, and whitespace at either end; `teams[].name` is the slug alphabet `[A-Za-z0-9._-]` with at least one letter or digit | Validation fails suggesting a form that parses (`push`, `admin`, `core-team`); [section 42](#42-collaborators-and-teams-permissions-and-slugs) | -| Secret and variable names, and the variable value cap | `name: deploy-token` parsed; the sealed PUT 422ed mid-apply; a variable value over 48 KB 422ed | Names are ASCII letters, digits, and underscores, no leading digit, no `GITHUB_` prefix in any case; a variable value is at most 49152 bytes of UTF-8 | Validation fails naming the entry's path; [section 43](#43-secret-and-variable-names-and-the-value-cap) | -| Check-suite preferences: `app_id` | `app_id: 0` and a negative id were PATCHed on every run; a repeated id was collapsed by GitHub with nothing reading it back | `app_id` is a positive integer and appears once | Validation fails naming the entries; [section 44](#44-check-suite-preferences-app_id) | -| Interaction limits: enums, the cap range, and a closed section | `expiry: two_weeks` or `expires_at` 422ed on every apply and never converged | `limit` and `expiry` are GitHub's enums; `max_open_pull_requests` is 1 to 1000; the section takes only `limit`, `expiry`, `pull_request_creation_cap`, and `pull_request_creation_bypass` | Validation fails naming the key and the rule; [section 45](#45-interaction-limits-enums-the-cap-and-the-closed-section) | -| Deploy keys: a public SSH key only | Any two-field string, a pasted private key included, planned a create and reached the request layer before the 422 | `key` is ` [comment]` with the algorithm one of GitHub's seven; `ssh-dss` is refused naming the date GitHub dropped DSA; a private key is refused without being echoed | Validation fails naming the entry by title; [section 46](#46-deploy-keys-a-public-ssh-key-only) | -| Webhooks: events, content type, `insecure_ssl`, and the url | `events: [pushes]`, `content_type: JSON`, `insecure_ssl: 2`, or `url: hooks.example.com/ci` 422ed at apply after other sections wrote | Events from GitHub's repository list (or `*`); `json` or `form`; `"0"`, `"1"`, `0`, or `1`; an absolute URL | Validation fails naming the entry and the accepted values; [section 47](#47-webhooks-events-content-type-insecure_ssl-and-the-url) | -| Secret scanning patterns must compile | `pattern: "([a-z"` parsed; check saw a missing pattern; apply hit the bulk-create 422 | `pattern`, `start_delimiter`, `end_delimiter`, `must_match`, and `must_not_match` pass a syntax check that first translates the PCRE-only forms Hyperscan accepts | Validation fails naming the field and the reason; a snapshot leaves out a live pattern the check cannot verify, with a note; [section 48](#48-secret-scanning-patterns-must-compile) | -| Rulesets: enforcement, bypass actors, ref-name tokens, rule parameters | `enforcement: enabled`, `include: ["~all"]`, a Team actor without `actor_id`, or `grouping_strategy: allgreen` 422ed after earlier sections wrote | `enforcement` is `active`, `evaluate`, or `disabled`; bypass actors are typed from the spec; a `~` value is `~ALL` or `~DEFAULT_BRANCH`; the 23 known rule types carry typed parameters, and an unknown type passes through | Validation fails naming the entry, key, and accepted values; [section 49](#49-rulesets-enforcement-actors-tokens-and-parameters) | -| Milestones: `due_on` is a day | `due_on: 2026-01-15` compared unequal to the `08:00:00Z` GitHub echoes and drifted forever; `T00:00:00Z` stored the previous day; `null` and offset timestamps passed through | A calendar day `YYYY-MM-DD`, or a UTC timestamp read for its day; written as noon UTC; `null` and offsets are refused | Validation fails naming the day form; a snapshot writes the day; [section 50](#50-milestones-due_on-is-a-day) | -| Environments: declared-off protection converges, and GitHub's refusals move to parse | `wait_timer: 0`, `prevent_self_review: false`, `reviewers: []` on an unprotected environment drifted forever; a two-false `deployment_branch_policy` 422ed | The disabled values are the baseline, so they converge; refused at parse: `wait_timer` outside 0 to 43200 or fractional, more than 6 reviewers, both policy flags true or both false, `prevent_self_review: true` without reviewers, a policy `type` outside `branch` and `tag` | Validation fails naming the fix (`deployment_branch_policy: null` spells "any branch"); [section 51](#51-environments-disabled-defaults-and-the-refusals) | -| Autolinks: charset, the `` placeholder, overlapping prefixes, the live flag | An empty or bad-charset `key_prefix` or a template without `` 422ed; a recreate sent `is_alphanumeric: true` over a live `false` | Refused at parse: an empty `key_prefix`, a character outside GitHub's set, a template without ``, two prefixes where one begins the other; a recreate keeps the live flag | Validation fails naming the key and an example value; [section 52](#52-autolinks-charset-placeholder-and-overlapping-prefixes) | -| YAML merge keys resolve | `<<: *base` survived as a literal `<<` field and rode into the create payload | `<<` merges the aliased mapping, as the Probot Settings app's parser did | No error: an entry that carried a literal `<<` key now gets the merged fields instead; [section 53](#53-yaml-merge-keys-resolve) | -| Library: a parsed ruleset entry carries `target` and `enforcement` | `SettingsFile` left both keys optional on a ruleset entry, so `{ rulesets: [{ name: "main" }] }` typed as one | Both keys are required on the parsed entry, the one `SettingsFile` and `sectionModule("rulesets").plan` take; the settings file still omits either and the parse fills `branch` and `active` | The literal fails to compile (`TS2739`, naming the missing keys); parse the document through `validateSettings`, or declare both keys; [section 54](#54-library-a-parsed-ruleset-entry-carries-target-and-enforcement) | -| Branches: a `restrictions` block carries `users` and `teams` | `restrictions: {}`, or a block naming only `apps` or only `users`, parsed clean and the protection PUT 422ed at apply | Both lists are required on the block (`[]` when none) and `apps` stays optional; `restrictions: null` lifts the push restriction; `dismissal_restrictions: {}` and `bypass_pull_request_allowances: {}` stay legal | Validation fails naming the two lists and the `null` form, with zero requests; [section 55](#55-branches-a-restrictions-block-carries-users-and-teams) | -| Library: `plan()` and `snapshot()` resolve to a `Result` | `await labels.plan(ctx, declared)` resolved to the plan and rejected on a denied read, a duplicated live pair, or a live body the section could not reconcile | Both resolve to a neverthrow `Result`: the plan or snapshot on `Ok`, a `SectionFailure` on `Err`; a rejection is left for the wrong-context refusal, a client that throws instead of answering, and `BUG:` invariants | Reading `.ops` or `.value` off the awaited value fails to compile (`TS2339`); `rejects.toThrow` assertions on a section call pass a resolved promise through; [section 56](#56-library-plan-and-snapshot-resolve-to-a-result) | -| A closed section's unrecognized key names the entry by index | `collaborators[octocat]: declares "permision", which this section does not recognize ...` | `collaborators[0] (username "octocat"): declares "permision", which this section does not recognize ...`; under a wrapper, `collaborators.entries[0] (username "octocat")` | Anything that greps the bracket for the entry's identity needs the new spelling; [section 57](#57-a-closed-sections-unrecognized-key-names-the-entry-by-index) | -| Library: `GitHubClient` and `ArtifactUploader` answer, never reject | `tryRequest()` and `tryGraphql()` rejected for a request with no HTTP answer (not sent, the transport failed, a GraphQL body off the wire contract); `upload()` rejected to report a failed upload | Both port methods resolve to a `ClientAnswer` whose third arm is `{ failed }`, the whole line; `upload()` resolves to `{ uploaded: true }` or `{ failed }`. A test double that still throws is read as a broken contract: the failure is reported, never classified | A double returning `void` from `upload()`, or a caller reading `.data` off a `ClientAnswer` without narrowing `failed`, fails to compile; [section 58](#58-library-githubclient-and-artifactuploader-answer-never-reject) | -| `next` publishes on a release-PR refresh | The pre-release v3 builds published a `next` pre-release from every green push to `main` that changed the shipped surface | The pre-release publishes when release-please creates or refreshes the release PR, which a releasable commit (feat, fix, perf, revert, or a breaking marker) does; a merge of hidden types alone publishes nothing | No error: `@next` keeps resolving, to the last releasable commit's pre-release; a build of one exact commit is the packaged commit under its `build/*` tag; [section 59](#59-next-publishes-on-a-release-pr-refresh) | -| Library: `GitHubClient` lists through `tryList()` | The verbs walked a list page by page through `tryRequest("GET", path?per_page=100&page=N)` and stopped on a short page | The port has a third member, `tryList(path, {perPage, until})`, resolving to a `ClientAnswer` of page bodies; `GitHubApi` follows GitHub's `Link` header through `@octokit/plugin-paginate-rest` | A double or client without `tryList` fails to compile, naming the missing member; [section 60](#60-library-githubclient-lists-through-trylist) | - -## 1. The defaults-file fallback - -Blast radius first: with `repos: "*"`, every discovered repository that has no `.github/settings.yml` now receives the whole defaults document, where v2 skipped it. A defaults file with `labels: {_undeclared: delete, entries: [...]}` deletes labels on all of them. - -1. Run the fleet workflow with `mode: check` on v3 and read which targets say `applying the defaults file: the repository has no .github/settings.yml on its default branch`. -2. For each, decide: give the repository its own file, or accept the defaults as its complete settings. -3. Targets that used a partial file as an overlay on the defaults now need the full document. Build it with [layering](../operate/layering.md): the old defaults file is the lowest layer, the old target file the highest, and `rendered-file` is the document the target applies. - -The token also matters: a target the token cannot read Contents on fails with an error naming `Contents: read`, where v2 could mistake the denial for a missing file. Grant the permission or drop the target from `repos`. - -The [multi-repo guide's fallback section](../operate/multi-repo.md#fallback-for-repositories-without-a-settings-file) owns the rule. - -## 2. undeclared becomes _undeclared - -Every list section wrapper, and every nested one (`environments[].variables`, `environments[].secrets`, `environments[].deployment_branch_policies`, `environments[].deployment_protection_rules`), spells the knob `_undeclared`. - -```yaml settings -labels: - _undeclared: keep - entries: - - name: bug - color: "d73a4a" -``` - -The old spelling fails validation before any section runs, so a check run finds every stale file at once. A v2 `labels` wrapper in `.github/settings.yml` produces this error: - -```text -.github/settings.yml has malformed section entries: labels: Unrecognized key: "undeclared"; the wrapper's policy key "undeclared" was renamed to "_undeclared" in v3 (a directive, like _layering) - write _undeclared: keep or _undeclared: delete. Fix these values in the settings file (only the named keys are validated; extra fields pass through, except in closed sections and strict nested objects like actions.cache, which reject unrecognized keys) -``` - -The underscore marks the action's directives: `_undeclared` (live axis), `_layering` (merge time), and `_remove` (merge time, on one keyed entry), and nothing else ([section 16](#16-underscore-keys-are-directives-never-notes)). The [undeclared policy](../reference/undeclared-policy.md) page owns the knob. - -## 3. Layering only in mode: render - -If a v2 setup relied on the defaults merge for anything beyond "no file, use the defaults", it becomes a two-step workflow: a `mode: render` step folds the layers into `rendered-file`, and the apply or check step runs that file. The [layering guide](../operate/layering.md) has the rules, the worked example, and the workflow. - -The fold is not the v2 merge. Three differences to audit when you rebuild an old overlay: - -| In v2's defaults merge | In a `mode: render` fold | -|---|---| -| Lists always replaced: a target's `labels` replaced the defaults' | Every list section unions by its key under the default `layering: deep` ([section 28](#28-_layering-takes-replace-shallow-or-deep)); set `layering: replace` (or `_layering: replace` on the section) to keep the old replacement | -| A nested `null` in the target survived as a value (`pages.cname: null` removed the domain) | The same: `null` wins and is written as the value, the empty or off state on GitHub. A key with no empty state refuses it at validation ([section 31](#31-null-wins-and-means-empty-and-_remove-drops-an-entry)) | -| A `null` section in the target opted out of the defaults' section | Only `pages: null` and `interaction_limits: null` are legal, and they turn the site or the limit off rather than opting out; any other whole-section `null` fails validation. To drop a lower entry, write `_remove: true` on it | - -## 4. Commas and newlines in a settings-file path - -`mode: render` takes its layers as a newline- or comma-separated list in `settings-file`, and the separators are the same in every mode. So no settings file can be named with a comma or a newline in its path any more. v2 read `settings-file: settings,prod.yml` as one file; v3 apply refuses it: - -```text -the "settings-file" input is "settings,prod.yml", which contains a list separator: apply mode reads exactly one settings file, and only mode: render takes a newline- or comma-separated list. Name one file, or set mode: render to fold the list into one document -``` - -Check mode says the same with `check mode`. The fix is a rename: move the file to a path without the separator (`settings-prod.yml`) and point `settings-file` at it. - -## 5. repos-result rows spell skipped-sections - -Every key inside the `repos-result` map is spelled like the outputs themselves: kebab-case. The one camelCase key, `skippedSections`, is gone. - -```text -{"o/r": {"result": "partial", "source": "central", "skipped-sections": ["actions_variables"]}} -``` - -A step that read `fromJSON(steps.settings.outputs.repos-result)['o/r'].skippedSections` now reads `null`; rename the key in the expression. - -## 6. The three outputs are always set - -| Output | v2 | v3 | -|---|---|---| -| `result` | Every mode | Every mode | -| `skipped-sections` | Apply, check, snapshot; the pre-release `merge` mode set it to `""` | Every mode, `""` when none | -| `repos-result` | Multi-repo runs and `snapshot-dir` only; unset elsewhere | Every mode; `{}` for a run over one repository or a render | - -A step that used an unset `repos-result` to tell a single-repo run from a fleet run must test `repos-result == '{}'` instead. The exit rule did not move: 1 exactly when `result` is `failed`, or `drift` in `mode: check`. - -## 7. One --json envelope - -Every `gsac` subcommand prints one object under `--json`, `result` first: - -| Command | v2 | v3 | -|---|---|---| -| `check`, `apply`, `render`, `snapshot` | `{"skipped-sections": "", "result": "clean"}` | `{"result": "clean", "skipped-sections": [], "repos-result": {}}`: the list is a list, the map a map | -| `validate` | `{"file": ..., "valid": true, "sections": [...]}` | `{"result": "valid", "file": ..., "sections": [...]}` | -| `permissions` | `{"labels": "", ...}` | `{"result": "valid", "file": ..., "grant": {"labels": "", ...}}` | -| `init` | `{"file", "repository", "result", "skippedSections", "failedSections", "grant"}` | `{"result", "file", "repository", "skipped-sections", "grant"}`; `failedSections` is gone, since a failed section now fails init | -| any failure | `{"file", "valid": false, "problem"}` or `{"result": "failed", "problem"}`; a mode command's fatal problem and a parser error printed no `problem` | `{"result": "failed", "file"?: ..., "problem": ...}`; a mode command that fails before any target runs prints `problem` beside its three outputs, a parser error or a crash prints it alone. A target that fails while running has no `problem`: its errors are on stderr and its row in `repos-result` | - -## 8. A failed snapshot section fails the target - -Every mode now applies one crash rule: a section whose read throws (an API error) fails its target, `result` is `failed`, the run exits 1, and no snapshot file is written for that target. v2 wrote the file without the section and reported `partial` with exit 0. - -A value the section's own schema rejects already failed the target in v2; that case did not move. A denial under `on-missing-permission: warn` still skips the section and still reports `partial`. - -`gsac init` follows: a failed section refuses to write the settings file, with the errors above naming the section and the fix. - -## 9. Library: the public entry and the v3 names - -For `@vivswan/github-settings-as-code` consumers. The old form is the pre-release v3 builds' (v2.0.0's package was private and exported nothing), as in sections 24 and 29. Before and after, one program: - -```text -pre-release import { GithubApi, checkRepository, validateSettings, runForRepo } from "@vivswan/github-settings-as-code"; - const { settings, warnings } = validateSettings(doc)._unsafeUnwrap(); - await checkRepository(client, { repo, settings, onMissingPermission: "fail", sections: SectionSelection.ALL }); - -v3 import { GitHubApi, checkRepository, validateSettings } from "@vivswan/github-settings-as-code"; - import { runForRepo } from "@vivswan/github-settings-as-code/internal"; - const { settings, log } = validateSettings(doc)._unsafeUnwrap(); - await checkRepository(client, repo, settings); -``` - -The package now has two entries, and one naming family per layer: - -| | pre-release | v3 | -|---|---|---| -| The entry | One, 192 names, all pinned | The public entry: the names the [library page](../reference/library.md#the-api-by-group) tables document, under semver. The internal entry, `@vivswan/github-settings-as-code/internal`: everything else the action, the CLI, and the tests import, with no promise | -| The verbs | `checkRepository`, `applyRepository`, `snapshotRepository`, `snapshotRepositories`, `validateSettings`, plus `foldLayers` and `mergeLayers` for a merge | The same five, plus `mergeSettings`; every verb takes its inputs positionally and one options object of the same knobs (`sections`, `onMissingPermission`, `io`, ...), each defaulted as the action's input | -| The reports | `RepoRunReport` (`log`), `SnapshotReport` (`log`), `validateSettings` (`warnings: string[]`) | `CheckReport`, `ApplyReport`, `SnapshotReport`, `MergeReport`, `ValidateReport`: one `log: CollectedLine[]` on every report, the annotation level kept beside each line | - -Every rename, old to new. An old name fails to compile, naming the missing export; the new name is in the public entry unless the row says the internal entry: - -| pre-release | v3 | -|---|---| -| `GithubApi` | `GitHubApi` | -| `GithubClient` | `GitHubClient` | -| `MissingPermissionPolicy` | `OnMissingPermission` (the input's name) | -| `checkRepository(client, { repo, settings, onMissingPermission, sections }, io?)` | `checkRepository(client, repo, settings, { onMissingPermission?, sections?, io?, secretEnv? })`; the document's `secretSource` is `validateSettings`'s knob | -| `applyRepository(client, { repo, settings, onMissingPermission, sections }, io?)` | `applyRepository(client, repo, settings, { onMissingPermission?, sections?, io?, secretEnv? })` | -| `RepoRunReport` | `CheckReport`, `ApplyReport` | -| `RepoRunOptions` | `CheckOptions`, `ApplyOptions` (the knobs alone); the engine's `RepoRunOptions` is in the internal entry | -| `snapshotRepository(client, repo, { sections?, onMissingPermission?, io? })` | Unchanged call; its options type is `SnapshotOptions` | -| `SnapshotLibraryOptions` | `SnapshotOptions` | -| `validateSettings(doc, { source?, sections?: ReadonlySet })` returning `{ settings, warnings: string[] }` | `validateSettings(doc, { source?, sections?: SectionSelection, io? })` returning `{ settings, log: CollectedLine[] }` (`ValidateOptions`, `ValidateReport`) | -| `foldLayers(layers, sourceLabel, layering, io)` | `mergeSettings(layers, { source?, layering?, io? })` returning `{ settings, notices, yaml, log }` (`MergeOptions`, `MergeReport`); `foldLayers` itself is in the internal entry | -| `mergeLayers`, `canonicalDocument`, `renderCanonicalYaml`, `renderSnapshotYaml` | The internal entry; `MergeReport.yaml` and `SnapshotReport.yaml` carry the rendered file | -| `validateSettingsDoc` | The internal entry: the engine's boundary, which prints its warnings to an `Io`; `validateSettings` collects them into `log` | -| `runForRepo`, `preflightProbe`, `skippedSectionKeys`, `RepoRunResult`, `RepoResult` | The internal entry | -| `SnapshotResult`, `RenderableSnapshot`, `SNAPSHOT_SCHEMA_URL` | The internal entry; `SnapshotReport.yaml` already carries the schema pin in its header | -| `REPO_RESULTS`, `SNAPSHOT_RESULTS`, `MERGE_RESULT` | `RUN_RESULTS` (worst first: `failed`, `drift`, `partial`, `skipped`, `applied`, `clean`, `snapshot`, `rendered`) | -| `SnapshotRunResult` | `RunOutcome` | -| `worstOf(results, check)`, with `check` picking the floor of an empty list | `worstOf(results)`; an empty list throws, since every run concludes over at least one target | -| `INPUT_DECLS`, `InputDecl`, `InputName`, `MODES`, `Mode`, `FILTER_INPUTS`, `SNAPSHOT_INPUTS`, `SNAPSHOT_ONLY_INPUTS`, `SNAPSHOT_REJECTED_INPUTS`, `parseSnapshotFileConfig`, `SnapshotFileConfig`, `DEFAULT_PRIVATE_REPOS`, `DEFAULT_SETTINGS_FILE` | The internal entry | -| `MERGE_INPUTS`, `MERGE_ONLY_INPUTS`, `MERGE_REJECTED_INPUTS` | `RENDER_INPUTS`, `RENDER_ONLY_INPUTS`, `RENDER_REJECTED_INPUTS` in the internal entry ([section 29](#29-mode-merge-is-mode-render)) | -| `resolveTargets`, `ResolvedTargets`, `TargetsConfig`, `RunFlowConfig` | The internal entry | -| `capturingIo`, `planRedaction`, `publicDetail`, `toPublicView`, `PublicTargetView`, `PRIVATE_REPOS_POLICIES`, `PrivateReposPolicy` | The internal entry | -| `getRepoFile`, `createVisibilityResolver`, `RepoVisibility`, `SECRET_RESPONSE_WITHHELD`, `SECRET_TRANSPORT_WITHHELD`, `TraceIo` | The internal entry | -| `applyMarkerInjection`, `PRIVATE_REPORT_CHANNELS`, `ISSUE_TITLE`, `MARKER_LABEL`, `MARKER_LABEL_CONFIG` | The internal entry | -| `AFFILIATIONS`, `ARCHIVED_FILTERS`, `FORKS_FILTERS`, `VISIBILITY_FILTERS`, `DiscoveryProblem` | The internal entry | -| `stripNulls`, `describeOptOut`, `OptOutNotice` | Removed: the fold reads no `null` as a marker ([section 31](#31-null-wins-and-means-empty-and-_remove-drops-an-entry)); `describeRemoval` and `RemovalNotice` describe a `_remove` | -| `quoteList`, `RERUN_ADVICE`, `ProblemOf`, `SettingsProblem`, `LayerProblem`, `CentralFileProblem`, `TopLevelShape` | The internal entry | -| `DOCUMENT_DIRECTIVE_KEYS`, `PROBOT_PARITY_KEYS`, `UndeclaredPolicy`, `UndeclaredPolicyList`, `MustBeNever` | The internal entry; `UNDECLARED_POLICY_SECTIONS` and `UndeclaredPolicySection` stay public | -| `denialPosture`, `readGating`, `writeGatedReads`, `sectionOperations`, `SectionMeta`, `KeyedListLayering`, `grantFor`, `PatResource`, `SectionPermission` | The internal entry | -| `Justification`, `PlannedOpBase`, `Tolerance`, `Unverifiable` | The internal entry | -| `concludeSnapshot` set `repos-result` only in the dir form; `concludeMerge` set no `repos-result` | Every conclude sets the three outputs | -| `FinishedSnapshot` carried `view` / `views`: each target already projected into a `SnapshotTargetView` | `FinishedSnapshot` carries `target` / `targets`: each a `TargetOutcome` whose `detail` is sealed for a hidden target; `concludeSnapshot` opens it through `publicDetail` / `toPublicView` | -| `SnapshotTargetView` (snapshot's own redacted projection) | Removed; the shared redaction-safe projection is `PublicTargetView` in the internal entry, which now carries the snapshot `file` | - -## 10. One redacted label in every mode - -Every hidden target is labelled `private repository #N`, numbered in target order, whatever the mode. A run over one repository (`repository:` with `private-repos: redact`, or `snapshot-file`) is a fleet of one, so its label is `private repository #1`: - -```text -v2: warning: private repository: drift - repository. details hidden: ... -v3: warning: private repository #1: drift - repository. details hidden: ... -``` - -The `artifact` report channel heads that run's report `` for the same reason. A filter matching the bare label needs the `#1`. - -Snapshot targets ride the same seal now: a private target's notes, file path, and section detail close sealed exactly as a multi-repo apply target's do, and a private target that fails gets the same one-line annotation (`private repository #N: failed - `) a fleet target gets. The [private repositories guide](../operate/private-repositories.md#one-seal-every-mode) owns the rule. - -## 11. Two live items under one identity fail the section - -v2 picked one silently. The factory sections refused only a claimed pair; milestones, rulesets, webhooks, custom properties, secret scanning patterns, environment secrets and variables kept the last one listed; workflows matched the first; teams acted on both. - -v3 refuses every live list the same way, whether or not the settings file declares the pair: the list sections, the nested environment lists, and the seven reads that had no guard before. Those seven are the teams listing, the workflows listing, the environment listing, the pinned environments, the protected-branch listing, the GraphQL protection rules, and the protection-rule Apps an environment can enable. - -Each pair is named the same way, the key first and the server id beside it when one exists: - -```text -webhooks: GitHub holds webhooks that resolve to one identity: "https://ci.example.com/hook (hook id 11)" and "https://ci.example.com/hook (hook id 12)". This section manages one webhook per identity, so it cannot tell them apart; delete all but one of each on GitHub, then run again -``` - -Snapshot says the same. Delete the duplicates on GitHub, then re-run. The protection-rule Apps list is the one read whose refusal can follow a write: for an environment the run creates, GitHub serves the list only once the environment's PUT has landed, so that refusal lands after it; for an existing environment it lands at plan, before any write. - -## 12. The webhook snapshot placeholder leads with SECRET_ - -```yaml settings -webhooks: - entries: - - config: - url: https://ci.example.com/hook - secret: $SECRET_WEBHOOK_601 # v2 wrote $WEBHOOK_SECRET_601 -``` - -Every snapshot secret now leads with `SECRET_`, the store name second. A file holding the old reference keeps working with its old export; to move, change the reference in the file and the exported variable together (`WEBHOOK_SECRET_601` becomes `SECRET_WEBHOOK_601` in both), or re-snapshot and export the new name. - -## 13. One wording per concept in drift lines and notes - -Anything that greps the check output for these lines needs the new spelling. Two templates reach every site: - -- the cannot-verify line: the webhook secret, the write-only `check_suite_preferences` note, `interaction_limits.expiry`, and the repository toggles GitHub cannot read back (`enable_git_lfs`); -- the left-out line: rulesets, webhooks, the teams and collaborators snapshot notes, an inherited interaction limit, and the secondary snapshot reads (actions, environments, repository) a denied grant skips under `on-missing-permission: warn`; a denied primary read still says `skipped`. - -| Line | v2 | v3 | -|---|---|---| -| A webhook's label | `webhooks["https://ci.example.com/hook"].active` | `webhooks[https://ci.example.com/hook].active` | -| A field mismatch (milestones, rulesets, collaborators, teams, every list section) | `milestones[v1].state: "closed" != "open"` and `teams[platform]: live role "read" != declared "write"` | `milestones[v1].state: declared "closed" != live "open"; apply will set the declared value` | -| A webhook's events | `... declared [...] != live [...] (compared order-insensitively)` | one line per element: `webhooks[].events: missing "release"` | -| A value check mode cannot compare | `... so the declared value cannot be verified; apply re-sends it on every run so rotations propagate` | `