Skip to content
On this page

Library

The engine behind the action is also an npm package, @vivswan/github-settings-as-code: ESM only, Node 22.14 or newer. Everything the action can do, a program can do through it: validate a settings document, merge layers, check or apply one repository, snapshot one, run the action's flows, discover targets, and compose the private report.

Install

Three ways in, one package:

bash
npm install @vivswan/github-settings-as-code@next     # the pre-release of the main commit the release PR was last refreshed on; ask for it until the first stable release
npm install @vivswan/github-settings-as-code          # the released version (npm dist-tag latest), a 0.0.0 placeholder until the first stable release
npm install github:Vivswan/github-settings-as-code#<packaged sha>   # one packaged commit: a build tag's or a release tag's

bun add takes the same three forms. A pre-release version looks like 2.0.1-main.446.20260913.g95d081d; the Versioning section says how the three relate.

The github: form installs a packaged commit: the child of one main commit, carrying that commit's tree plus lib/pkg/ (the library build), lib/settings.schema.json (the published schema), and lib/index.js (the action bundle), all built from that commit; a package CI minted names its workflow run in its message, one minted by hand in the release recovery does not.

  • Its package.json carries none of the scripts npm's git fetcher takes as a reason to install devDependencies and run a prepare step (prepare, prepack, build, the install hooks), so nothing is built or installed on your side.
  • Every green push to main mints one under the tag build/<position>.<sha7> and then prunes the tags to the ten newest: once ten newer commits have been packaged, a tag is deleted and GitHub may collect its commit, so a pin taken from an old tag can go on the next merge. A durable pin names a release tag's commit (git rev-parse v2.1.0) or an npm version.
  • The tags up to v2.0.0 point at release commits on main from when main still committed the bundle, not at packaged commits; the packaged commits minted before the per-commit tags lived on the build branch, deleted on 2026-09-13.

To build the package from a checkout instead, bun install && bun run build:schema && bun run build:lib writes lib/settings.schema.json and lib/pkg/: the entry and the internal entry with their declarations, and the CLI, the files the manifest's exports and bin point at.

The two entries

Import pathWhat it holdsPromise
@vivswan/github-settings-as-codeThe documented library: every name in the API by group, and nothing elseSemver: a rename or a removal is a major, listed in the upgrading guide
@vivswan/github-settings-as-code/internalWhat the action, the CLI, and this repository's tests import beyond the library (the input declarations, the engine's per-repository run, the redaction helpers, ...)None: a name here may move or go in any release. Nothing outside this repository should import it

Two more paths ride along: the built settings.yml JSON Schema (./settings.schema.json) and the package's own manifest (./package.json).

The entries are src/index.ts and src/internal.ts, each a list of re-exports. The tables below are the public entry's contract: a test derives the list of names from this page and fails when src/index.ts exports a name no table names, or names one it does not export.

The API by group

One table per group: the name, what kind of thing it is, and what it says. Each group has one short example; the full signatures are in the bundled declarations.

How a call reports failure depends on its group:

  • A call that reads or parses input, or runs a whole flow (validateSettings, mergeSettings, readSettingsFile, parseRepoSlug, discoverRepos, parseRecipient, runSingle, runMulti, runRender, ...), returns a neverthrow Result (or ResultAsync) whose error is a typed Problem; describeProblem renders one as the message the action would print.
  • The repository verbs, checkRepository, applyRepository, and snapshotRepository, always resolve to a report: its result field carries the outcome (clean, drift, applied, partial, skipped, failed, snapshot) and its outcomes say what each section did. Every result word of every mode, the render's rendered included, is a RunOutcome; RUN_RESULTS ranks them worst first and worstOf folds a run's targets through that ranking.
  • Report delivery: deliverArtifactReport never throws and returns { uploaded: true } or { warning }. Two calls throw instead: encryptReport on a recipient parseRecipient would have rejected (validate it first), and openReportChannel when asked for the artifact channel without an ArtifactUploader.

Every verb takes its inputs positionally and one options object of the same knobs, each defaulted as the action's input of the same name:

KnobDefaultOn
sectionsSectionSelection.ALL: every declared section, none requiredvalidate, check, apply, snapshot
onMissingPermission"fail"check, apply, snapshot
source"the settings document", or "the rendered settings document" for a mergevalidate, merge
layering"deep"merge
ioA collector: the lines the call prints come back as the report's log, a CollectedLine[] (the annotation level beside each line); with your own Io the log is emptyevery verb

The examples continue from one another and form one program (the docs tests compile them in page order): each name is imported once, in the first example that uses it, and later examples reuse it, as they do settings (the Validate group's validated document), client (the Client group's GitHubApi), repo (the Check group's parsed slug), and config in the Io example (a SingleConfig, the action's parsed inputs, declared there).

Validate and merge

NameKindSays
validateSettingsfunctionValidate a parsed document into the branded ValidatedSettings every other verb takes; a null is a declared value, refused where the key has no empty state
ValidateOptionstypesource, sections, io, undeclared (the action's input of that name: the fallback policy below a list's wrapper and the file's top-level _undeclared), secretSource (who authored the document: operator, the default, honors $NAME secret references; target refuses them)
ValidateReporttypesettings and log
ValidatedSettingstypeThe document as zod parsed it, branded by validation, every knobbed list in {_undeclared, entries} form with its policy resolved; validateSettings, mergeSettings, and a snapshot that did not fail hand one out
mergeSettingsfunctionFold an ordered list of Layers into one validated document, as mode: render does: each layer validated alone, folded, validated again; its yaml is byte for byte the file mode: render writes, the fold in the canonical order every rendered document shares (sections in execution order, keys as the schema declares them, the entries of every list section by identity; branches, bypass_actors, reviewers, and scalar lists as written), so no layer's key order reaches the file
MergeOptionstypesource, layering ("deep" unless set), undeclared (unset unless given), io
MergeReporttypesettings, notices (one per _remove: true entry that dropped a lower entry), yaml (the file text mode: render writes, byte for byte), and log (the lines the fold printed)
LayertypeOne layer: its name (a path, usually) and its parsed doc
Layeringtype"replace", "shallow", or "deep": how every list section's entries fold across layers, by the section's key
RemovalNoticetypeA _remove: true entry that dropped what a lower layer declared: the layer and the entry's path
describeRemovalfunctionOne notice as the line the action prints
readLayerFilesfunctionRead paths into Layers in order; the first unreadable file is the problem
readSettingsFilefunctionRead and parse one YAML file, given its role (settings-file, defaults-file, layer, central-file), so the problem's advice fits
SettingsFileRoletypeThe four roles
parseSettingsDocfunctionParse YAML text into an unknown document
ProblemtypeEvery typed failure a call can return, keyed by code
describeProblemfunctionA Problem as the message the action prints
ts
import { describeProblem, readSettingsFile, validateSettings } from "@vivswan/github-settings-as-code";

const validated = readSettingsFile(".github/settings.yml", "settings-file").andThen((doc) =>
  validateSettings(doc, { source: ".github/settings.yml" }),
);
if (validated.isErr()) throw new Error(describeProblem(validated.error));
const { settings, log } = validated.value;
console.log(log.map((entry) => `${entry.level ?? "log"}: ${entry.line}`));
ts
import { mergeSettings, readLayerFiles } from "@vivswan/github-settings-as-code";

const merged = readLayerFiles(["fleet.yml", "team.yml"]).andThen((layers) => mergeSettings(layers));
if (merged.isErr()) throw new Error(describeProblem(merged.error));
console.log(merged.value.yaml, merged.value.notices.length);

Schema

NameKindSays
SettingsFileconstThe zod schema of the whole document, and its inferred type
SECTION_KEYSconstEvery section key in execution order
SectionKeytypeOne of them
UNDECLARED_POLICY_SECTIONSconstThe list sections whose wrapper takes _undeclared beside _layering; environments, branches, and workflows layer by key too, through a {_layering, entries} wrapper of their own (LIST_SECTIONS in the schema module)
UndeclaredPolicySectiontypeOne of them

The schema subpath serves the built JSON Schema.

ts
import { SECTION_KEYS, SettingsFile } from "@vivswan/github-settings-as-code";
import schema from "@vivswan/github-settings-as-code/settings.schema.json" with { type: "json" };

const parsed = SettingsFile.safeParse({ labels: [] });
console.log(parsed.success, SECTION_KEYS.length, schema.$schema);

Client

NameKindSays
GitHubApiclassThe REST and GraphQL client the action uses: retries, throttling, the pinned API version, trace redaction
GitHubApiOptionstypeIts constructor's options: token required; io (the trace sink), baseUrl, apiVersion, retryBaseMs (real milliseconds per plugin second), scheduler (the throttling limiter), userAgent optional
GitHubClienttypeThe port every verb reads and writes through, and the interface a test double implements; it answers, never rejects
ClientAnswertypeWhat one request ends in: data, an error (GitHub's answer, an ApiError), or failed (the whole line for a request with no HTTP answer: not sent, the transport failed, a GraphQL body off the wire contract)
DEFAULT_API_VERSIONconstThe X-GitHub-Api-Version the action pins
ApiErrortypeA failed request as the port returns it: status, message, body
GraphqlOptypeA GraphQL operation as the port takes it
RequestMarktypeThe per-request options; carriesSecret marks a payload holding a resolved secret
isPermissionErrorfunctionWhether an ApiError is a denial
isRateLimitErrorfunctionWhether an ApiError is the rate limit

A request whose payload holds a resolved secret reaches the client marked carriesSecret, and whatever error that client returns or throws for it is withheld on the engine's side of the port, so an echoed value never reaches an outcome, the log, or a report, whichever client is in use.

ts
import { GitHubApi } from "@vivswan/github-settings-as-code";

const client = new GitHubApi({ token: process.env.GITHUB_TOKEN ?? "" });

Check and apply

NameKindSays
checkRepositoryfunctionPlan and diff every active section without writing
CheckOptionstypesections, onMissingPermission, io, and secretEnv, the environment applyRepository resolves the secret references from; check mode reads none
CheckReporttyperepo, result, outcomes (one SectionOutcome per section), preflightDenied, log
applyRepositoryfunctionExecute the plan: the repository converges on the document
ApplyOptionstypeThe same knobs as CheckOptions
ApplyReporttypeThe same shape as CheckReport
parseRepoSlugfunctionowner/name into a RepoRef, or the problem naming what is wrong with it
RepoReftypeA parsed slug: owner, name, slug
SectionSelectionclassWhich sections run (only) and which must fully apply (required); SectionSelection.of(...) is the one constructor, SectionSelection.ALL the default
OnMissingPermissiontype"fail" or "warn": how a read the token is denied classifies
SectionOutcometypeOne section's end state: key, status, detail, and the denial's httpStatus when there was one
ts
import { checkRepository, parseRepoSlug, SectionSelection } from "@vivswan/github-settings-as-code";

const repo = parseRepoSlug("octo-org/api");
if (repo.isErr()) throw new Error(describeProblem(repo.error));
const report = await checkRepository(client, repo.value, settings, {
  onMissingPermission: "warn",
  sections: SectionSelection.ALL,
});
console.log(report.result, report.outcomes.map((o) => `${o.key}: ${o.status}`), report.log);

Snapshot

NameKindSays
snapshotRepositoryfunctionRead one repository's supported sections back as a settings document
snapshotRepositoriesfunctionThe same over several repositories in order; a failed target never stops the rest
SnapshotOptionstypesections, onMissingPermission, io
SnapshotReporttyperepo, result, outcomes, takenAt (the moment the reads began; the report states it, since the file carries no date), log, and on any result but failed the settings and the yaml mode: snapshot writes: the schema pin, one comment per note, then the document in the canonical order, with no timestamp, so two snapshots of an unchanged repository are the same bytes
SectionSnapshotOutcometypeOne section's end state: snapshot, skipped, unsupported, or failed, with its detail
ts
import { snapshotRepository } from "@vivswan/github-settings-as-code";

const snapshot = await snapshotRepository(client, repo.value);
console.log(snapshot.result, snapshot.yaml ?? "(failed: no document)");

Flows

NameKindSays
executeRunfunctionThe executor the action and the CLI share: a RunConfig plus a face's RunDeps runs to its RunEnd
RunDepstypeWhat a face hands in: the io, the client factory, and the artifact uploader only the Actions runner has
RunEndtypeHow the run ended: its exitCode, and the fatal Problem when it never reached a target
RunConfigtypeThe action's parsed inputs: a SingleConfig, MultiConfig, RenderConfig, or SnapshotConfig, discriminated by kind
parseConfigfunctionBuild a RunConfig from an input reader, the environment, and the face's RunCapabilities, the way the action does; a face without an artifact upload is refused private-report: artifact here
RunCapabilitiestypeWhat the face can do: artifactUpload, whether it hands the run a workflow-artifact uploader
InputReadertype(name) => string: how parseConfig reads an input
ConfigEnvtypeThe environment parseConfig reads
runSinglefunctionOne repository from a local settings file, in check or apply
SingleConfigtypeIts config
SingleOutcometypeIts result: the target's result, outcomes, and detail
runMultifunctionThe fleet: repos-dir files, the repos list, discovery, the defaults fallback
MultiConfigtypeIts config
TargetOutcometypeOne fleet target's result, with its source
runRenderfunctionFold settings files into rendered-file; no token, no API call
RenderConfigtypeIts config: settingsFiles, renderedFile, layering, undeclared
FinishedRendertypeIts result: the layers and the written file
runSnapshotfunctionThe live settings of one repository to a file, or of every fleet target to a directory
SnapshotConfigtypeIts config, in the file or the dir form
FinishedSnapshottypeIts result: every target's public view
concludeRunfunctionA finished single or multi run into the outputs, the summary, and the exit code
concludeRenderfunctionThe same for a finished render
concludeSnapshotfunctionThe same for a finished snapshot
failRunfunctionA fatal Problem into the failed outputs and exit 1
RunOutcometypeEvery result word of every mode
RUN_RESULTSconstThose words ranked worst first
worstOffunctionThe worst result across a run's targets

All four flows conclude alike: the three outputs (result, skipped-sections, repos-result) are always set, the result is worstOf the targets, and the exit code is 1 exactly when it is failed, or drift in check mode.

ts
import { concludeRender, failRun, runRender, silentIo } from "@vivswan/github-settings-as-code";

const io = silentIo();
const renderExitCode = runRender(
  { settingsFiles: ["base.yml", "team.yml"], renderedFile: "rendered.yml", layering: "deep" },
  io,
).match(
  (finished) => concludeRender(io, finished),
  (problem) => failRun(io, problem),
);

Discovery

NameKindSays
discoverReposfunctionThe repositories a token can see, filtered
DiscoveryFilterstypeThe filters: visibility, archived, forks, affiliation, topics, exclude
DEFAULT_DISCOVERY_FILTERSconstThe action's defaults
parseReposInputfunctionThe repos input form: slugs, or "*" for discovery
resolveCentralTargetsfunctionThe <owner>/<name>.yml files of a repos-dir as targets, with warnings for the files it skipped
dedupeTargetsfunctionMerge the central and the remote targets, central first; a remote target whose slug a central one already names is dropped with a notice
TargettypeA CentralTarget or a RemoteTarget
CentralTargettypeA target with a settings file in the repos-dir
RemoteTargettypeA target whose settings file is fetched from the repository itself
ts
import { DEFAULT_DISCOVERY_FILTERS, discoverRepos } from "@vivswan/github-settings-as-code";

const found = await discoverRepos(client, { ...DEFAULT_DISCOVERY_FILTERS, topics: ["managed"] });
if (found.isErr()) throw new Error(describeProblem(found.error));
console.log(found.value.repos.map((r) => r.slug));

Report

NameKindSays
composeReportfunctionThe private, unredacted markdown report for one target
ReportInputtypeWhat it renders: the target, the admin repository, the run URL, the mode and result, the outcomes, the transcript
encryptReportfunctionSeal a report to an age recipient
parseRecipientfunctionValidate an age recipient before sealing to it
openReportChannelfunctionThe issue or artifact channel the action delivers through
PrivateReportChanneltypenone, issue, issue-on-failure, or artifact
deliverArtifactReportfunctionThe artifact half, behind an uploader you supply; never throws
ArtifactUploadertypeupload(name, file): the port the Actions runner implements; it resolves to { uploaded: true } or { failed } with the reason
ts
import { encryptReport, parseRecipient } from "@vivswan/github-settings-as-code";

const recipient = process.env.REPORT_PUBLIC_KEY ?? "";
const checked = parseRecipient(recipient);
if (checked.isErr()) throw new Error(describeProblem(checked.error));
const sealed = await encryptReport(recipient, "# report");

Sections

NameKindSays
SECTIONSconstEvery section module in execution order
sectionModulefunctionOne module by key
SectionModuletypeA module: its key, endpoints, permission, plan(), snapshot() when it has one, and validate() on a list section (its file-only checks, run by document validation)
ValidatedInputtypeWhat plan() takes: one section's value read off a ValidatedSettings document (settings.labels); only validation mints it, so a hand-built entry list does not compile
ValidatedBrandtypeThe mark a ValidatedInput carries: a type-level property holding the section key the value was validated as, with no runtime field; a declaration spells a planner's input as the section's value & ValidatedBrand<"labels">
SectionInputtypeThe section's value without the brand, as the schema types it; what a list module's validate() hook takes, since it runs inside validation
sectionGrantfunctionThe PAT grant a section needs, as prose
allEndpointsfunctionEvery declared REST route, tagged with its owner
allGraphqlOpsfunctionEvery declared GraphQL operation, tagged with its owner
TaggedEndpointtypeA route beside the section that declares it
endpointMethodfunctionThe method half of a Route
endpointPathfunctionThe path half of a Route
Routetype"GET /repos/{owner}/{repo}/labels" and its kin
EndpointDecltypeA REST endpoint as a section declares it: the route, the statuses it tolerates, the permission
GraphqlOpDecltypeA GraphQL operation as a section declares it
planContextfunctionThe context plan() reads through, from your client and a RepoRef
PlanContexttypeThat context
snapshotContextfunctionThe context snapshot() reads through: the plan context plus the denial policy
SnapshotContexttypeThat context
DenialPolicytypeThe policy as snapshot() sees it; only snapshotContext mints one
SectionPlantypeWhat plan() resolves to on Ok: the operations, notes, and drift
SectionSnapshottypeWhat snapshot() resolves to on Ok: the section's value and notes
SectionFailuretypeWhat plan() or snapshot() resolves to on Err: the whole line as message, and a kind for policy ("permission-denied" also carries the section, the detail, and the HTTP status)
ts
import { planContext, sectionGrant, sectionModule, snapshotContext } from "@vivswan/github-settings-as-code";

const labels = sectionModule("labels");
console.log(labels.key, Object.keys(labels.endpoints), sectionGrant(labels));

A module's plan() and snapshot() are callable directly, each over a context built from your GitHubClient and a RepoRef:

  • planContext(module, client, repo) for plan().
  • snapshotContext(module, client, repo, onMissingPermission) for snapshot(); the fourth argument is the OnMissingPermission, "fail" or "warn". The context carries it as a DenialPolicy only this factory mints, so a literal object cannot stand in for one.
  • A context belongs to the module it was built from: labels.plan(planContext(branches, ...)) does not compile, and a module handed another section's context at runtime rejects with an error naming both sections before it reads anything.
  • plan() takes the section's value off a validated document (settings.labels, a ValidatedInput<"labels">), never a list you built by hand. Only validateSettings(), mergeSettings(), and a snapshotRepository() that did not fail mint that type.
  • plan() and snapshot() resolve to a neverthrow Result: the plan or snapshot on Ok, a SectionFailure on Err (a denied read, a live state the section cannot reconcile, a duplicated live pair). Neither rejects for anything a settings file or the network can cause; a rejection is the wrong-context refusal above, a GitHubClient that throws instead of answering (breaking its contract), or a BUG: invariant.
  • A module's own snapshot() value is unbranded and goes through validateSettings() first. So the file-only checks (two entries naming one label) have run before any planner reads.
  • The brand names the section: on a module named by its key, a validated branches list is not a labels input. The erased SectionModule view is one key on both sides, as it is for contexts, so only the key-named module carries that check.
  • A null section value (pages, interaction_limits) carries no brand, since it holds nothing to check.

Prefer checkRepository() and snapshotRepository() for the whole document: one run over every selected section, permission failures classified per section, and one report or rendered file at the end.

ts
import type { SectionFailure, SectionPlan, SectionSnapshot, ValidatedInput } from "@vivswan/github-settings-as-code";

const declaredLabels: ValidatedInput<"labels"> | undefined = settings.labels;
if (declaredLabels === undefined) throw new Error("the document declares no labels");
const planned = await labels.plan(planContext(labels, client, repo.value), declaredLabels);
if (planned.isErr()) {
  const failure: SectionFailure = planned.error;
  throw new Error(`${failure.kind}: ${failure.message}`);
}
const labelsPlan: SectionPlan = planned.value;
const read = await labels.snapshot?.(snapshotContext(labels, client, repo.value, "warn"));
let labelsSnapshot: SectionSnapshot<"labels"> | undefined;
if (read !== undefined) {
  if (read.isErr()) throw new Error(read.error.message);
  labelsSnapshot = read.value;
}
console.log(labelsPlan.ops.length, labelsSnapshot?.value, labelsSnapshot?.notes);

Io

NameKindSays
IotypeThe output port every flow writes to: log, debug, annotate, summary, output, and the mask pair
collectingIofunctionAn Io that records lines, outputs, and summary blocks, masked as the action's log is
CollectedLinetypeOne recorded line and, for an annotation, its level
silentIofunctionAn Io that drops everything
prefixedIofunctionAn Io that attributes every line to a target
maskRegistryfunctionThe mask pair an Io implementation needs, over a sink such as core.setSecret
MaskPairtypemask(value) and the masked set
redactRangesfunctionThe one redactor: every registered value in a text becomes ***, overlapping occurrences as one
AnnotationLeveltypenotice, warning, or error
OutputNametyperesult, skipped-sections, or repos-result
ts
import { collectingIo, concludeRun, runSingle, type SingleConfig } from "@vivswan/github-settings-as-code";

declare const config: SingleConfig;
const collected = collectingIo();
const exitCode = await runSingle(client, config, collected.io).match(
  (target) => concludeRun(collected.io, { kind: "single", mode: config.mode, target }),
  (problem) => failRun(collected.io, problem),
);
console.log(exitCode, collected.outputs.result, collected.lines.map((entry) => entry.line));

CLI

The package's bin entries, github-settings-as-code and gsac, run the same flows from a terminal: check, apply, and render take the action's inputs as --flags, and validate and permissions read a settings file alone. The command line guide has every command, the flag rule, and the exit codes.

Versioning

The package and the action share one version, the one in .release-please-manifest.json (release-please rewrites package.json from it), so a settings file that validates on the library validates on the action of the same version.

npm dist-tagPublishes onVersionInstall
nextEvery refresh of the release PR: release-please creates or refreshes it when a releasable commit (feat, fix, perf, revert, or a breaking marker) lands on mainThe manifest's next patch, then -main.<count>.<date>.g<sha7>: 2.0.1-main.446.20260913.g95d081dnpm install @vivswan/github-settings-as-code@next
latestEvery release cutThe released version: 2.1.0. Until the first stable release it names the 0.0.0 placeholder that reserved the package name: a packument always carries latest (npm/registry REGISTRY-API.md, "dist-tags: an object with at least one key, latest"), so the first publish took it, and the first stable release moves it; ask for @next until thennpm install @vivswan/github-settings-as-code
noneEvery green push to main (build/<position>.<sha7>, the ten newest kept) and every release tagThe commit itselfnpm install github:Vivswan/github-settings-as-code#<packaged sha>

The npm dist-tag latest is not the git tag latest: the git tag names the packaged commit of the newest main commit (every green push moves it forward, never back), the dist-tag names the newest release on the registry.

  • A pre-release version is a pure function of its main commit. 2.0.1-main.446.20260913.g95d081d reads:
    • 446: the commits reachable from it along first parents (git rev-list --count --first-parent <sha>), one more per merge to main.
    • 20260913: its committer date in UTC.
    • g95d081d: its short sha; the g marks a git object id, as git describe writes it (a bare all-digit sha such as 0123456 would be read by npm as the number 123456).
    • Two runs for one commit mint the same string, whenever they run.
  • A pre-release sorts above the last release and below the next one whatever its bump, and later commits on main sort later: npm compares the count first, and it grows by one with each merge (the date is for the reader; two merges on one day share it).
    • A release merge refreshes no release PR, so it publishes no pre-release: after 2.1.0 publishes, next names the last pre-release below it (2.0.1-main...) until the first releasable commit of the next cycle opens the next release PR, whose refresh publishes 2.1.1-main.<count>.<date>.g<sha7>. npm install ...@next resolves the dist-tag whatever the ordering.
  • next moves only to a descendant. The pre-publish guard resolves the sha in every published -main. version in its full checkout and places it against this run's commit:
    • a pre-release whose source is a strict descendant: this run is stale and publishes nothing, whatever order the two runs finished in;
    • a sha the checkout cannot resolve, or one that is neither ancestor nor descendant (off main): ignored, with a notice in the log;
    • the run's own version already on the registry (a rerun of that commit): publishes nothing.
  • A next build appears when release-please creates or refreshes the release PR, and nowhere else: the release hook update-release-pr.yml publishes in the run that refreshed it, from the commit the refresh was built on.
    • release-please refreshes the PR only when the release notes change (always-update is off in release-please-config.json), which a releasable commit does: feat, fix, perf, revert, or a breaking marker. A merge of hidden types alone (chore, build, ci, test, docs, refactor) refreshes nothing and publishes nothing, a dependency bump under build(deps) included.
    • No check of the repository's own decides a publish. The guard above is a safety on registry state: a rerun of the run, or a stale retry after a newer commit published, publishes nothing, so next never moves backward; a published source the checkout cannot place is ignored with a notice.
  • latest publishes nothing when the version is already there or when the dist-tag latest names a newer release (a rerun of an older release's job); it does not look at next.
  • Every registry read misses the CDN cache (a cached packument lags a publish by up to 300 s), and a next publish job holds the npm-publish lane until the registry's record shows its version (up to 15 reads, 20 s apart: three of the first five publishes were still unreadable after 80 s, so the hold covers that lag with margin).
    • So the run after it judges against a record that carries it. Neither dist-tag moves backward on what its run could see.
  • The residual window: a publish the registry has not made readable within that bound is invisible to the run after it, which then moves next back to its older version.
    • That run fails with an error naming the drift once the record shows both versions; it warns if its own never shows within the bound.
    • It passes without naming the drift if its own shows while the overtaken one still does not.
    • A rerun of it publishes nothing and passes, and the next release-PR refresh moves next forward again (its commit descends from every published one).
    • No run moves a dist-tag by hand: trusted publishing authenticates npm publish alone, not npm dist-tag add.
  • Both channels publish through npm trusted publishing (OIDC) from this repository's CI workflow: no registry token exists anywhere.
    • npm attaches a provenance attestation to every version CI publishes, which npm audit signatures checks in a project that installs it.
  • The github: form installs a packaged commit's lib/pkg/, built from its source commit by the same workflow run that built its lib/index.js, with no registry and no build step on your side.

Publishing setup

The package exists on the registry and every version on it is CI-published through the trusted publisher: GitHub Actions, owner Vivswan, repository github-settings-as-code, workflow ci.yml (the caller of both hooks), no environment. Publishing access is "Require two-factor authentication and disallow tokens", so the workflow's OIDC identity is the only thing that can publish; nothing is published by hand.

Built from main at 1a25adbSource: docs/reference/library.md