Ecosystem Version Contract
July 3, 2026 · View on GitHub
Why the contract exists
As the plugin ecosystem grew from a single shared helper into a library that five active plugins depend on simultaneously, version drift became a recurring source of build and runtime failures. Early coordination relied on informal conventions: each plugin author pinned the Common submodule manually, updated plugin.json's commonVersion field by hand, and relied on code review to catch mismatches. In practice this meant that after every Common release at least one plugin would ship with a stale commonVersion, an outdated targetFramework, or a forbidden manifest field that the host silently ignored — until a new Lidarr build started enforcing it and broke loading.
A reviewer audit (completed May 2026) catalogued these coordination failures across the original four plugin repos; Amazonmusicarr now follows the same contract as the fifth plugin. The audit found repeated instances of:
commonVersioninplugin.jsonlagging behind the submodule SHA by one or more releases.manifest.jsonandplugin.jsondisagreeing on the version field after a patch release.- Forbidden fields (
minimumVersion,targets) surviving in shipped manifests. - Plugins targeting
net6.0long after the ecosystem moved tonet8.0.
The version contract is the machine-checkable answer to those problems. It encodes the invariants as a single JSON specification (scripts/parity-spec.json) and enforces them in CI via ecosystem-parity-lint.ps1.
Schema: the versionContract section of parity-spec.json
The versionContract object is the authoritative source for ecosystem-wide version requirements. Each field is described below.
description
Human-readable summary of the contract's purpose and the lint phase that enforces it.
commonVersionSource
Path (relative to the Common repo root) of the MSBuild file that provides the canonical package <Version> value consumed by ecosystem-parity-lint.ps1. The repository VERSION file must contain the same value; scripts/tests/Test-VersionSourceContract.ps1 enforces that lockstep so release scripts and MSBuild metadata cannot drift.
Current value: Directory.Build.props
targetFramework
The required target framework moniker for all shipping plugin assemblies.
Current value: net8.0
forbiddenTargetFrameworks
Target framework monikers that must not appear in any plugin's plugin.json, manifest.json, or .csproj. Any occurrence causes a lint failure.
Current values: net6.0, net7.0, netstandard2.0, netstandard2.1
allowedHostAssemblies
The exhaustive list of Lidarr host DLL names that a plugin is permitted to reference. Any assembly reference not on this list is treated as a packaging error because the plugin would be bundling a host-owned DLL.
forbiddenPackageContents
DLL names that must not appear in a plugin's packaged ZIP. These are either host-owned assemblies, framework assemblies that Lidarr provides at runtime, or Common assemblies that must be merged/internalized. Bundling them causes AssemblyLoadContext type-identity collisions when multiple plugins are loaded simultaneously.
This list is consumed by packaging gates such as PluginPackaging.targets, Test-PackageClosure.ps1, and the per-plugin packaging/expected-contents.txt checks. ecosystem-parity-lint.ps1 does not build or inspect ZIPs; it enforces structural and manifest/version invariants only.
How a plugin author validates compliance
Run the shared lint gate locally before opening a PR:
pwsh ext/Lidarr.Plugin.Common/scripts/ci/run-plugin-lint-gates.ps1 `
-RepoPath . `
-CommonRoot ext/Lidarr.Plugin.Common `
-Mode ci
That runner calls ecosystem-parity-lint.ps1 -Check all, so it covers both structural parity and version-contract parity. To debug only the version-contract subset:
pwsh ext/Lidarr.Plugin.Common/scripts/ecosystem-parity-lint.ps1 `
-RepoPath . `
-Mode ci `
-Check VersionContract `
-CommonRoot ext/Lidarr.Plugin.Common
To run all parity checks directly:
pwsh ext/Lidarr.Plugin.Common/scripts/ecosystem-parity-lint.ps1 `
-RepoPath . `
-Mode ci `
-Check all `
-CommonRoot ext/Lidarr.Plugin.Common
The script exits 0 on success and non-zero on any failure. CI treats a non-zero exit as a blocking gate.
What the lint catches
The VersionContract check enforces the following invariants:
-
commonVersiondrift — thecommonVersionfield inplugin.jsonmust match the<Version>element inDirectory.Build.props. A plugin that pins the submodule to a newer Common release but does not updatecommonVersionwill fail this check. -
plugin.json/manifest.jsonversion parity — theversionfield inplugin.jsonand theversionfield inmanifest.jsonmust be identical. Intra-repo drift (e.g., bumpingplugin.jsonfor a patch but forgettingmanifest.json) is forbidden. -
Forbidden fields — fields listed under
pluginJson.forbiddenFieldsandmanifestJson.forbiddenFieldsinparity-spec.json(currentlyminimumVersionandtargets) must not be present in either file. -
Forbidden target frameworks —
targetFrameworkvalues that appear inforbiddenTargetFrameworksmust not appear in either manifest file or in any.csprojinside the plugin'ssrc/tree.
Package ZIP contents are validated outside parity lint. The expected-contents updater builds the plugin package through Common's New-PluginPackage, which removes merged sidecars and verifies the main plugin DLL no longer has external Lidarr.Plugin.Common or Lidarr.Plugin.Abstractions AssemblyRefs before zipping. When the legacy -RequireCanonicalAbstractions flag is used, it also verifies no Lidarr.Plugin.Abstractions.dll sidecar is present. Then generate-expected-contents.ps1 -Check compares the ZIP against each plugin's packaging/expected-contents.txt.
How to bump Common version and propagate to plugins
Today the propagation is a manual, repo-by-repo process. The steps are:
-
In Common: update both
VERSIONandDirectory.Build.propswith the same new version value, runpwsh scripts/tests/Test-VersionSourceContract.ps1, updateCHANGELOG.md, tag the release, and push. -
In each plugin repo (amazonmusicarr, applemusicarr, brainarr, qobuzarr, tidalarr): a. Check out the intended Common tag or SHA under
ext/Lidarr.Plugin.Common. b. Runbash ext/Lidarr.Plugin.Common/scripts/repin-common-submodule.sh --sha-from-submodule --stage --verify --path ext/Lidarr.Plugin.Commonto update the gitlink andext-common-sha.txttogether. c. Update thecommonVersionfield inplugin.jsonto match the new Common<Version>. d. Update theversionfield inmanifest.jsonif the plugin version itself is also being bumped. e. Runbash ext/Lidarr.Plugin.Common/scripts/repin-common-submodule.sh --verify-only --path ext/Lidarr.Plugin.Commonand confirm exit code0. f. Runpwsh ext/Lidarr.Plugin.Common/scripts/ci/run-plugin-lint-gates.ps1 -RepoPath . -CommonRoot ext/Lidarr.Plugin.Common -Mode ciand confirm exit code0. g. Confirm.gitea/workflows/ci.ymlruns the verify-only submodule guard. If the repo also carries.github/workflows/*.ymlmirrors, confirm they either run the same guard or are documented as non-gating mirrors. h. Runpwsh ext/Lidarr.Plugin.Common/scripts/update-plugin-expected-contents.ps1 -RepoPath . -Checkand confirm the package contents match the plugin'spackaging/expected-contents.txt. i. Open a PR with the submodule bump and manifest updates.
Future automation may open coordinated submodule-bump PRs across all plugin repos automatically when a new Common version is published. Until that exists on the Gitea-primary path, the manual steps above are the required procedure.