Appearance
Upgrading from v2 to v3
Sixty-two breaks. Twenty-seven are for library consumers (sections 9, 22 to 29, 34, 35, 37 to 41, 45, 47, 49, 51, 54 to 56, 58, 60, 61, and 62), ten of those also refusals below, 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), 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.
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.
Run inputs and outputs
The detail is in v2-to-v3/run.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 |
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); 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 |
| 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 |
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 |
| 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 |
| 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 |
| 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 |
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 |
| 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 |
| 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 |
| The snapshot file is canonical and undated | The second header line read # Snapshot of owner/name taken <instant>; list entries sat in the order GitHub listed them | No dated line: the run's notice and the step summary say snapshot taken <instant>; 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 |
| 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 |
| 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 |
Discovery: exclude patterns are globs under an owned grammar | * was the one operator; ?, [, and a leading ! matched literally, so a pattern using them matched nothing | A glob matched by picomatch inside an owned grammar: *, ?, [abc], [!abc] or [^abc], one leading !, name characters, at most one / | A pattern that gains glob meaning matched nothing and matches now (!svc-* excluded nothing; it now excludes every repository not named svc-*); one outside the grammar (a leading ./, a run of stars, a second /, a regex token) fails the run before discovery starts, naming every flaw and its fix; for library callers DiscoveryFilters.exclude takes compiled ExcludePattern values; section 61 |
Settings-file keys and directives
The detail is in v2-to-v3/settings-file.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
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 |
| 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 |
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 |
| 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 |
Layering and the render step
The detail is in v2-to-v3/layering.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
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 to get merging back |
| 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 |
_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 |
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 |
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 |
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 |
Per-section behavior
The detail is in v2-to-v3/sections.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
| 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 |
| The webhook snapshot placeholder | $WEBHOOK_SECRET_<id> | $SECRET_WEBHOOK_<id> | No error: an old reference keeps resolving from its old export; a new snapshot writes the new name, so move both together; section 12 |
| 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 |
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 |
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 |
| 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 |
| Environment secrets and variables plan through the shared engines | Their own wording | The engines' wording | Only a grep over the output notices; section 20 |
Parse-time refusals: repository-level sections
The detail is in v2-to-v3/refusals-repository.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
| 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 |
| Pages: GET-only site fields, the source path, and the empty mapping | custom_404: true rode the PUT, was dropped, and drifted forever; source.path: /src 422ed; pages: {} ran and configured nothing | url, html_url, status, custom_404, protected_domain_state, pending_domain_unverified_at, and https_certificate are refused; source.path is / or /docs; an empty mapping is refused naming its three fixes | Validation fails naming the key; section 38 |
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 |
| 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 |
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 |
| 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 |
Parse-time refusals: list sections
The detail is in v2-to-v3/refusals-lists.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 <algorithm> <base64> [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 |
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 |
| 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 |
| 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 disabled, active, or evaluate; 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 |
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 |
| 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 |
Autolinks: charset, the <num> placeholder, overlapping prefixes, the live flag | An empty or bad-charset key_prefix or a template without <num> 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 <num>, two prefixes where one begins the other; a recreate keeps the live flag | Validation fails naming the key and an example value; section 52 |
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 |
| 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 |
Library API
The detail is in v2-to-v3/library.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
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<K>, minted by validateSettings, mergeSettings, or snapshotRepository | A raw list fails to compile, naming ValidatedBrand; section 35 |
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 |
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 |
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 |
Library: a target carries its parsed RepoRef | CentralTarget and RemoteTarget carried slug: string, and parseReposInput resolved to { slugs: string[], discover } | Both carry repo: RepoRef (owner, name, slug), parsed once where the input is read; parseReposInput resolves to { repos: RepoRef[], discover } | Reading .slug off a target or .slugs off the parsed input fails to compile (TS2339); read target.repo.slug and parsed.repos; section 62 |
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<unknown[]> 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 |
Releases and tags
The detail is in v2-to-v3/releases.md.
| Break | v2 | v3 | What the old form does now |
|---|---|---|---|
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/<position>.<sha7>, 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 says what to pin instead |
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 |
Order of operations
- Rename any settings file whose path contains a comma, rename
undeclaredto_undeclaredin every settings file, and move every other underscore key into a YAML comment; the v2 line accepts the old spellings only, so do all three together with the pin move. - Rename
skippedSectionstoskipped-sectionsin every step expression that readsrepos-result, and repointjqfilters at the--jsonenvelope. - Where a snapshot wrote a
$WEBHOOK_SECRET_<id>reference, change the reference and its exported variable toSECRET_WEBHOOK_<id>together, or re-snapshot. - Move the pin to
@v3withmode: check. A layered setup also renamesmode: mergetomode: renderandmerged-filetorendered-file, and writesdeepwhere a layer or thelayeringinput saidmerge; v3 refuses both before check runs. The parse-time refusals (sections 36 to 52 and 55) surface here, before any request, at most five schema issues per section; fix each and run check again. - Read the fallback notices and the drift; add render steps where a target needs the old overlay behavior; delete duplicated live items the sections now refuse; declare or empty the live values apply now refuses to write over (section 33).
- In a layered setup, write
_layering: replacewhere a higher list must still win, omit from every layer any key whosenullmeant "stop managing this", and drop a lower entry with_remove: true(sections 28 to 31). - Switch back to apply.