Skip to content
On this page

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.

Breakv2v3What the old form does now
defaults-file is a fallbackMerged under every multi-repo target; a target without a settings file was skippedApplied whole to a target that has no settings file; a target with its own file is applied as written, never mergedNo 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 newlinesettings-file: settings,prod.yml named one fileA comma or newline is a list separator in every modeApply 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 setrepos-result existed only in multi-repo runs and the snapshot-dir form; unset elsewhereresult, skipped-sections, and repos-result are set on every run; repos-result is {} outside a fleetNo error. A step testing repos-result != '' to detect a fleet run now always passes; section 6
One --json envelope on the command linevalidate printed {file, valid, sections}, permissions a bare grant map, init skippedSectionsEvery subcommand prints {result, ...}; permissions puts its grants under grant, lists are listsA jq filter on the old keys reads null; section 7
A snapshot section that fails on its own fails the targetresult: partial, exit 0, the file written without that sectionresult: failed, exit 1, no file for that target; init refuses to writeThe workflow step fails where it passed; section 8
One redacted label in every modeA single-repository run labelled its hidden target private repository; a fleet numbered them private repository #Nprivate repository #N everywhere; a run over one repository is #1No 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 notesPer-section spellings of "cannot verify", "left out", and field drift; quoted webhook labelsOne template eachOnly a grep over the output notices; section 13
GSAC_RETRY_BASE_MSRETRY_BASE_MS, undocumentedGSAC_RETRY_BASE_MS, in the inputs referenceNo error: an unknown environment variable is ignored, so a harness setting the old name waits real seconds; section 18
One wording for every faceThe 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 portA 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 fieldThe section fails with returned a body outside the documented shape; section 25
The snapshot file is canonical and undatedThe second header line read # Snapshot of owner/name taken <instant>; list entries sat in the order GitHub listed themNo dated line: the run's notice and the step summary say snapshot taken <instant>; the document renders in the canonical order the rendered file sharesNo 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 omitsCheck compared declared keys only; the ruleset and environment PUTs wrote the whole object, so an omitted live value was deleted while check said cleanCheck reports the omitted value as drift; apply refuses that entry's PUT and fails the sectionThe 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 writeA duplicate in a section the sections input excluded never ran; one in a selected section threw mid-run after earlier sections wroteEvery section's file-only checks run at validation, selected or not, before any requestA 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 nothingA 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.

Breakv2v3What the old form does now
The wrapper key is _undeclaredlabels: {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 notesAn unknown _note: ... at the top level was dropped silentlyThree 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 validationValidation fails before any section runs, naming the directives; section 16
File-wide _undeclared and the run input undeclaredThe policy lived on each section's wrapper onlyA 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 defaultNo 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 didNo 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.

Breakv2v3What the old form does now
Layering happens in mode: renderThe only merge was the defaults-file oneAn ordered list of files folds in a render step; apply and check take one final documentNo error. Reach for the two-step workflow to get merging back
The rendered file is the fold in the canonical orderThe pre-release v3 builds wrote the validated parse, keys in the schema's orderThe 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 yamlNo error: a committed rendered file reorders once; section 24
_layering takes replace, shallow, or deep, and every list section layers by keyNo layering; the pre-release v3 builds took merge or replace, and only labels and rulesets unioneddeep (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: renderNo render mode; the pre-release v3 builds spelled it mode: merge, merged-file, CLI merge, result mergedmode: render, rendered-file, CLI render, result rendered; five problem codes and four library flow exports carry the render spellingmode: 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 keyA higher list replaced the lower one wholeUnion 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 keysNo 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 entryIn 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 consumedValidation 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.

Breakv2v3What the old form does now
Two live items under one identity fail the sectionThe last one listed won silently in most sectionsEvery live list refuses, naming the pairThe 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 onlyA service hook was matched and deleted like any otherA service hook or url-less hook is outside the sectionIt is left alone and noted by snapshot; section 14
A ruleset without source_type is repository-ownedKept with a note under _undeclared: deleteDeleted like any other undeclared repository rulesetNo error: under _undeclared: delete the ruleset is deleted, where v2 kept it with a note; section 15
teams takes the _undeclared knobA plain array; an undeclared team was never listed or touchedteams: {_undeclared: keep, entries: [...]} accepted, default keep; delete revokes undeclared direct grantsNo 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 timeCheck mode read GET .../secrets/public-key and failed on a malformed keyThe first sealed PUT reads it at applyCheck mode issues one request fewer per secret family; a malformed key fails at apply; section 19
Environment secrets and variables plan through the shared enginesTheir own wordingThe engines' wordingOnly a grep over the output notices; section 20

Parse-time refusals: repository-level sections ​

The detail is in v2-to-v3/refusals-repository.md.

Breakv2v3What the old form does now
Repository: GET-only keys, commit-message pairs, topics, and security sub-keyshas_downloads: false drifted on every run; an illegal squash pair or a bad topic 422ed at applyRefused 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 topicsValidation fails naming the key and the fix; section 37
Pages: GET-only site fields, the source path, and the empty mappingcustom_404: true rode the PUT, was dropped, and drifted forever; source.path: /src 422ed; pages: {} ran and configured nothingurl, 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 fixesValidation fails naming the key; section 38
Actions: reported-only fields, a closed selected_actions, enums and boundsmaximum_allowed_days: 400 was re-PUT on every run; a misspelled selected_actions key was re-PUT silently foreverselected_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 booleanValidation fails naming the key and the fix; section 39
Code scanning and code quality setup: GET-only keys, languages, the runner pairschedule: weekly and a runner_label under runner_type: standard each drifted forever; languages: [javascript] never matched the pair GitHub reportsschedule 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 oneValidation 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_idapp_id: 0 and a negative id were PATCHed on every run; a repeated id was collapsed by GitHub with nothing reading it backapp_id is a positive integer and appears onceValidation fails naming the entries; section 44
Interaction limits: enums, the cap range, and a closed sectionexpiry: two_weeks or expires_at 422ed on every apply and never convergedlimit 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_bypassValidation fails naming the key and the rule; section 45

Parse-time refusals: list sections ​

The detail is in v2-to-v3/refusals-lists.md.

Breakv2v3What the old form does now
Labels: color and description are checked at parsecolor: red reached GitHub as a 422 and never converged; a 150-character description 422edcolor is six hex digits, the # optional; description is at most 100 charactersValidation fails naming the entry and the rule; section 36
Branches: protection shapes the file alone shows wrongA 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 422edRefused 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 goValidation 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 slugspermission: 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 digitValidation fails suggesting a form that parses (push, admin, core-team); section 42
Secret and variable names, and the variable value capname: deploy-token parsed; the sealed PUT 422ed mid-apply; a variable value over 48 KB 422edNames 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-8Validation fails naming the entry's path; section 43
Deploy keys: a public SSH key onlyAny two-field string, a pasted private key included, planned a create and reached the request layer before the 422key 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 echoedValidation fails naming the entry by title; section 46
Webhooks: events, content type, insecure_ssl, and the urlevents: [pushes], content_type: JSON, insecure_ssl: 2, or url: hooks.example.com/ci 422ed at apply after other sections wroteEvents from GitHub's repository list (or *); json or form; "0", "1", 0, or 1; an absolute URLValidation fails naming the entry and the accepted values; section 47
Secret scanning patterns must compilepattern: "([a-z" parsed; check saw a missing pattern; apply hit the bulk-create 422pattern, start_delimiter, end_delimiter, must_match, and must_not_match pass a syntax check that first translates the PCRE-only forms Hyperscan acceptsValidation 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 parametersenforcement: enabled, include: ["~all"], a Team actor without actor_id, or grouping_strategy: allgreen 422ed after earlier sections wroteenforcement 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 throughValidation fails naming the entry, key, and accepted values; section 49
Milestones: due_on is a daydue_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 throughA calendar day YYYY-MM-DD, or a UTC timestamp read for its day; written as noon UTC; null and offsets are refusedValidation fails naming the day form; a snapshot writes the day; section 50
Environments: declared-off protection converges, and GitHub's refusals move to parsewait_timer: 0, prevent_self_review: false, reviewers: [] on an unprotected environment drifted forever; a two-false deployment_branch_policy 422edThe 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 tagValidation fails naming the fix (deployment_branch_policy: null spells "any branch"); section 51
Autolinks: charset, the <num> placeholder, overlapping prefixes, the live flagAn empty or bad-charset key_prefix or a template without <num> 422ed; a recreate sent is_alphanumeric: true over a live falseRefused 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 flagValidation fails naming the key and an example value; section 52
Branches: a restrictions block carries users and teamsrestrictions: {}, or a block naming only apps or only users, parsed clean and the protection PUT 422ed at applyBoth 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 legalValidation fails naming the two lists and the null form, with zero requests; section 55
A closed section's unrecognized key names the entry by indexcollaborators[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.

Breakv2v3What the old form does now
Library: a documented public entry, an internal one, and the v3 namesThe pre-release v3 builds had one entry, 192 names; GithubApi, RepoRunReport, validateSettings returning warningsthe public entry holds the names the library page documents, the internal entry the rest; GitHubApi, CheckReport, every report carries logThe old import fails to compile, naming the missing export; every rename is in section 9
Library: one owner per refusal, one selection typeparseConfig(read, env); executeRun returned a number and took describe; artifact-uploader-missing; validateSettings took a SetparseConfig(read, env, capabilities); executeRun returns {exitCode, fatal?}; input-artifact-unsupported; SectionSelection everywhere; the central-file roleThe call fails to compile, naming the missing argument or member; section 23
Library: one problem code for a malformed documentThe pre-release v3 builds had settings-unknown-directives and settings-unknown-sections, one standalone stop eachBoth are lines of settings-malformed-sections, beside the section issuesA switch on Problem.code naming a deleted code fails to compile; section 27
Library: plan() takes only validator-minted inputIn the pre-release v3 builds labels.plan(ctx, [{name: "bug"}, {name: "Bug"}]) compiled, so a caller could skip the file-only checksThe parameter is ValidatedInput<K>, minted by validateSettings, mergeSettings, or snapshotRepositoryA raw list fails to compile, naming ValidatedBrand; section 35
Library: a parsed ruleset entry carries target and enforcementSettingsFile left both keys optional on a ruleset entry, so { rulesets: [{ name: "main" }] } typed as oneBoth 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 activeThe 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 Resultawait 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 reconcileBoth 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: invariantsReading .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 rejecttryRequest() 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 uploadBoth 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 classifiedA 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 RepoRefCentralTarget 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 pageThe 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-restA 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.

Breakv2v3What the old form does now
The build branch retiresEvery green push appended a packaged commit to the build branch; latest and the release tags pointed into itOne 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-13A 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 refreshThe pre-release v3 builds published a next pre-release from every green push to main that changed the shipped surfaceThe 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 nothingNo 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 ​

  1. Rename any settings file whose path contains a comma, rename undeclared to _undeclared in 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.
  2. Rename skippedSections to skipped-sections in every step expression that reads repos-result, and repoint jq filters at the --json envelope.
  3. Where a snapshot wrote a $WEBHOOK_SECRET_<id> reference, change the reference and its exported variable to SECRET_WEBHOOK_<id> together, or re-snapshot.
  4. Move the pin to @v3 with mode: check. A layered setup also renames mode: merge to mode: render and merged-file to rendered-file, and writes deep where a layer or the layering input said merge; 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.
  5. 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).
  6. In a layered setup, write _layering: replace where a higher list must still win, omit from every layer any key whose null meant "stop managing this", and drop a lower entry with _remove: true (sections 28 to 31).
  7. Switch back to apply.
Built from main at ab5102dSource: docs/upgrading/v2-to-v3.md