Skip to content
On this page

Upgrading from v2 to v3: library API ​

Sections 9, 23, 27, 35, 54, 56, 58, and 60 of the v2 to v3 guide, whose index page carries the summary table and the order of operations.

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-releasev3
The entryOne, 192 names, all pinnedThe public entry: the names the library page 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 verbscheckRepository, applyRepository, snapshotRepository, snapshotRepositories, validateSettings, plus foldLayers and mergeLayers for a mergeThe 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 reportsRepoRunReport (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-releasev3
GithubApiGitHubApi
GithubClientGitHubClient
MissingPermissionPolicyOnMissingPermission (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? })
RepoRunReportCheckReport, ApplyReport
RepoRunOptionsCheckOptions, 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
SnapshotLibraryOptionsSnapshotOptions
validateSettings(doc, { source?, sections?: ReadonlySet<SectionKey> }) 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, renderSnapshotYamlThe internal entry; MergeReport.yaml and SnapshotReport.yaml carry the rendered file
validateSettingsDocThe internal entry: the engine's boundary, which prints its warnings to an Io; validateSettings collects them into log
runForRepo, preflightProbe, skippedSectionKeys, RepoRunResult, RepoResultThe internal entry
SnapshotResult, RenderableSnapshot, SNAPSHOT_SCHEMA_URLThe internal entry; SnapshotReport.yaml already carries the schema pin in its header
REPO_RESULTS, SNAPSHOT_RESULTS, MERGE_RESULTRUN_RESULTS (worst first: failed, drift, partial, skipped, applied, clean, snapshot, rendered)
SnapshotRunResultRunOutcome
worstOf(results, check), with check picking the floor of an empty listworstOf(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_FILEThe internal entry
MERGE_INPUTS, MERGE_ONLY_INPUTS, MERGE_REJECTED_INPUTSRENDER_INPUTS, RENDER_ONLY_INPUTS, RENDER_REJECTED_INPUTS in the internal entry (section 29)
resolveTargets, ResolvedTargets, TargetsConfig, RunFlowConfigThe internal entry
capturingIo, planRedaction, publicDetail, toPublicView, PublicTargetView, PRIVATE_REPOS_POLICIES, PrivateReposPolicyThe internal entry
getRepoFile, createVisibilityResolver, RepoVisibility, SECRET_RESPONSE_WITHHELD, SECRET_TRANSPORT_WITHHELD, TraceIoThe internal entry
applyMarkerInjection, PRIVATE_REPORT_CHANNELS, ISSUE_TITLE, MARKER_LABEL, MARKER_LABEL_CONFIGThe internal entry
AFFILIATIONS, ARCHIVED_FILTERS, FORKS_FILTERS, VISIBILITY_FILTERS, DiscoveryProblemThe internal entry
stripNulls, describeOptOut, OptOutNoticeRemoved: the fold reads no null as a marker (section 31); describeRemoval and RemovalNotice describe a _remove
quoteList, RERUN_ADVICE, ProblemOf, SettingsProblem, LayerProblem, CentralFileProblem, TopLevelShapeThe internal entry
DOCUMENT_DIRECTIVE_KEYS, PROBOT_PARITY_KEYS, UndeclaredPolicy, UndeclaredPolicyList, MustBeNeverThe internal entry; UNDECLARED_POLICY_SECTIONS and UndeclaredPolicySection stay public
denialPosture, readGating, writeGatedReads, sectionOperations, SectionMeta, KeyedListLayering, grantFor, PatResource, SectionPermissionThe internal entry
Justification, PlannedOpBase, Tolerance, UnverifiableThe internal entry
concludeSnapshot set repos-result only in the dir form; concludeMerge set no repos-resultEvery conclude sets the three outputs
FinishedSnapshot carried view / views: each target already projected into a SnapshotTargetViewFinishedSnapshot 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

23. Library: one owner per refusal, one selection type ​

For @vivswan/github-settings-as-code consumers.

v2v3
parseConfig(read, env); the command line refused private-report: artifact before calling it, and the flows checked for the uploader again (artifact-uploader-missing)parseConfig(read, env, { artifactUpload }): the one refusal, input-artifact-unsupported; runSingle and runMulti no longer check
executeRun(cfg, deps): Promise<number>, with deps.describe rewording a fatal problemexecuteRun(cfg, deps): Promise<RunEnd>, { exitCode, fatal? }; failRun(io, problem) takes no wording hook
validateSettings(doc, { sections?: ReadonlySet<SectionKey> })validateSettings(doc, { sections?: SectionSelection }); SectionSelection.ALL for no allowlist
SettingsFileRole: settings-file, defaults-file, layerplus central-file, the repos-dir file a multi-repo target is read from

The same change reshaped validateSettingsDoc, parseSnapshotFileConfig, snapshotFileDestination, and writeReplacing. All four are internal-entry names, so the intro's rule applies.

27. Library: one problem code for a malformed document ​

For @vivswan/github-settings-as-code consumers. The left column is the pre-release v3 builds' shape, as in sections 24 and 29.

Pre-releasev3
settings-unknown-directives: an unknown underscore key stopped the run aloneA line of settings-malformed-sections: unknown underscore key: _owner. ...
settings-unknown-sections: an unknown section stopped the run aloneA line of the same problem: unknown top-level section: stickers (known: ...)

A switch on Problem.code that names either deleted code fails to compile. Drop the case; the lines are in the issues of settings-malformed-sections, before the section issues: the underscore line first, then the unknown-section line.

35. Library: plan() takes only validator-minted input ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in sections 24 and 29.

text
pre-release   const labels = sectionModule("labels");
              await labels.plan(planContext(labels, client, repo), [{ name: "bug" }, { name: "Bug" }]);
              // compiled: a caller could hand the planner a pair the validator refuses

v3            const { settings } = validateSettings(doc)._unsafeUnwrap();
              if (settings.labels !== undefined) {
                await labels.plan(planContext(labels, client, repo), settings.labels);
              }
              // settings.labels is the ValidatedInput<"labels"> the planner takes, or undefined when the file has no labels section

The old call fails to compile with Property '[validatedInput]' is missing in type '{ name: string; }[]' but required in type 'ValidatedBrand<"labels">'. Pass the section off a validated document: validateSettings(doc), mergeSettings(layers), or snapshotRepository(...) returns settings, and each section of it carries the brand. SectionInput<K>, the unbranded shape, stays exported for a custom module's validate hook.

54. Library: a parsed ruleset entry carries target and enforcement ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in sections 24 and 29. The settings file is unchanged: both keys stay optional there, and the parse fills target: branch and enforcement: active.

The parsed entry always carries both, so the full-payload PUT sends them and the comparison never reads a live value under either key as omitted. The parsed type says so, and a typed literal that omits them stops compiling.

text
pre-release   const doc: SettingsFile = { rulesets: [{ name: "main" }] };            // compiles

v3            const doc: SettingsFile = { rulesets: [{ name: "main" }] };            // TS2739: target and enforcement are missing
              const { settings } = validateSettings({ rulesets: [{ name: "main" }] })._unsafeUnwrap();  // parsed: both keys filled
              const doc: SettingsFile = { rulesets: [{ name: "main", target: "branch", enforcement: "active" }] };

sectionModule("rulesets").plan takes the parsed entry, so a hand-built entry handed to it needs both keys too.

An entry cast past the type gets two omitted-key drift lines, since nothing fills them after the parse.

56. Library: plan() and snapshot() resolve to a Result ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in sections 24, 29, 35, and 54.

A section never throws for what a user can cause. plan() and snapshot() resolve to a neverthrow Result: the plan or snapshot on Ok, a SectionFailure on Err, whose message is the whole line the action reports and whose kind names the policy the engine applies ("permission-denied" carries the section, the detail, and the HTTP status beside it). Every line is the one the thrown error carried.

text
pre-release   const plan = await labels.plan(ctx, declared);          // resolves to the plan, rejects on a denied read
              plan.ops.length;

v3            const planned = await labels.plan(ctx, declared);       // resolves to Result<SectionPlan, SectionFailure>
              if (planned.isErr()) throw new Error(planned.error.message);
              planned.value.ops.length;

The change hook of a planned operation, its capture hook, and its before, payload, and variables thunks return a Result too; a hook that used to throw its verification failure returns err(...) with the same text.

A rejection out of plan() or snapshot() now means one of three things: the module was handed another section's context (the guard the library page describes, unchanged), the GitHubClient you supplied threw instead of answering (section 58), or a BUG: invariant fired.

Fix: match on the Result (isErr(), match, or _unsafeUnwrap() in a test) where the awaited value was read directly, and assert Err where a test asserted a rejection.

58. Library: GitHubClient and ArtifactUploader answer, never reject ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in section 56.

GitHubApi never rejects. A request with no HTTP answer resolves to the failed arm of ClientAnswer, carrying the whole line the action reports (the request, the reason, the remedy). The reason is withheld where the request carried a secret, and for a GraphQL request where the repository is redacted.

The engine reads that arm wherever it read the throw:

  • a section fails with kind transport
  • discovery reports its transport problem
  • a multi-repo target fails with the line, where the run used to stop
  • the private-report channels warn without it
text
pre-release   const answer = await client.tryRequest("GET", path);      // rejects on a network failure
              if ("error" in answer) ...

v3            const answer = await client.tryRequest("GET", path);      // resolves to ClientAnswer<unknown>
              if ("failed" in answer) throw new Error(answer.failed);
              if ("error" in answer) ...

ArtifactUploader.upload() resolves to { uploaded: true } or { failed }; deliverArtifactReport renders failed into its warning as it rendered the throw. A client or uploader that still throws is not classified: a section reports it under kind thrown, the report channels warn with their slug-free line.

Fix: add the failed arm to every GitHubClient double and read it before error; return { uploaded: true } from every ArtifactUploader double.

60. Library: GitHubClient lists through tryList() ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in section 56.

  • Every paginated REST list read (labels, rulesets, the secrets envelopes, discovery's /user/repos, the report's issue scan) goes through the port's third member, tryList(path, { perPage, until }).
  • It resolves to a ClientAnswer<unknown[]> whose data holds one body per page, in order, as GitHub sent it: a bare list, or the {total_count, <key>: []} envelope, which the caller reads by its key.
  • GitHubApi follows GitHub's Link: <url>; rel="next" header through @octokit/plugin-paginate-rest, and keeps every page on the route the caller named, taking only the Link's query (per_page, page) from GitHub.
  • A 409 surfaces as the error it is.
  • The pre-release builds walked tryRequest("GET", path?per_page=100&page=N) themselves and stopped on a short page.
text
pre-release   tryRequest("GET", "/repos/o/r/labels?per_page=100&page=1")   // the engine asked page by page
              tryRequest("GET", "/repos/o/r/labels?per_page=100&page=2")

v3            tryList("/repos/o/r/labels", { perPage: 100 })               // resolves to { data: [page1Body, page2Body] }

Fix: add tryList to every GitHubClient double: answer the pages your tryRequest route table would (per_page=N&page=K from 1, until a page is short) and return their bodies as { data: [...] }; a client over another transport returns each page's body as received, and honors until by stopping after the first page it accepts.

62. Library: a target carries its parsed RepoRef ​

For @vivswan/github-settings-as-code consumers. The old form is the pre-release v3 builds', as in section 56.

  • CentralTarget and RemoteTarget carry repo: RepoRef (owner, name, slug) in place of slug: string: the slug is parsed once, where the input is read, and never re-split downstream.
  • parseReposInput resolves to { repos: RepoRef[], discover } in place of { slugs: string[], discover }, so the targets you hand dedupeTargets are built from those refs.
  • parseRepoSlug is the one constructor of a RepoRef; a "." or ".." owner or name is refused there.
text
pre-release   const { slugs } = parseReposInput(input)._unsafeUnwrap();
              const remote = slugs.map((slug) => ({ slug, source: "remote", origin }));
              for (const target of dedupeTargets(central, remote, notice, display)) use(target.slug);

v3            const { repos } = parseReposInput(input)._unsafeUnwrap();
              const remote = repos.map((repo) => ({ repo, source: "remote", origin }));
              for (const target of dedupeTargets(central, remote, notice, display)) use(target.repo.slug);

Fix: read target.repo.slug (or owner and name) where you read target.slug, and build a RemoteTarget from each RepoRef in parsed.repos.

Built from main at ab5102dSource: docs/upgrading/v2-to-v3/library.md