[ADR-0031] Public Preview Release and Versioning Policy¶
Status: Accepted
Date: 2026-08-04
Decision Makers: Nick Cipollina, Claude (design review)
Context¶
docs/mvp.md's Milestone 8 scope says "publish 0.x packages" without defining what that actually commits Compono to: whether the five packages version together or independently, what 0.x promises a consumer during the preview, how a breaking change gets communicated, and what must be true of a package before it's allowed into the first preview at all. Left undefined, "0.x" risks meaning nothing more than "not 1.0 yet" — an excuse for undisciplined churn rather than a real compatibility contract a consumer can plan around.
This isn't starting from zero. Compono/Compono.Generators/ Compono.XunitV3/Compono.NSubstitute/Compono.Bogus already publish continuously today: publish-preview.yaml pushes a fresh alpha prerelease to public nuget.org on every non-docs-only push to main (prereleaseIdentifier: alpha, via the shared LayeredCraft/devops-templates pipeline), and cosmere-tracker (Milestone 7's dogfooding target) already consumes real published prereleases from there (0.1.0-alpha.33 at time of writing) rather than a local feed. publish-release.yaml also already exists, firing on a GitHub Release being published. A release-drafter draft v0.1.0 GitHub Release already exists alongside the real v0.0.0 tag. docs/migrating- from-autofixture.md already documents an informal practice: "bump all four versions together when a newer alpha is needed." The release pipeline (version calculation, build/test gates, the two-workflow preview/production split, NuGet push via the shared devops-templates actions) is explicitly not being redesigned by this ADR — it's reused as-is. What's undecided is the policy layer on top of it: what version scheme, what compatibility promise, and what gate a package must clear before it's included.
Decision Drivers¶
- The existing "bump together" practice is already real, working behavior with a real external consumer (
cosmere-tracker) depending on it — a decision that overturns it has to justify breaking that, not just be theoretically cleaner. - A public preview needs a compatibility story a stranger can trust enough to adopt Compono in a real project, not just "anything can change, it's 0.x."
- The release pipeline itself is a solved problem (existing, working, shared org infrastructure) — this ADR's job is the policy the pipeline executes, not the pipeline's mechanics.
docs/mvp.md's non-goals list explicitly excludes "Stable 1.0 API" from the MVP — this ADR must not accidentally promise 1.0-grade stability during 0.x.
Considered Options¶
Versioning model¶
- Lockstep — one version number shared by all five packages, bumped together every release regardless of which package(s) actually changed.
- Independent per-package semver — each package versioned on its own change history.
Preview publishing identifier¶
- Keep the
alphaprerelease identifier —maincontinues publishing0.x.y-alpha.N. - Rename it to
preview—mainpublishes0.x.y-preview.Ninstead.publish-preview.yaml'sprereleaseIdentifierinput always produces a SemVer prerelease version (nuget.org anddotnet add packageboth treat any--suffixed version as prerelease, excluded from a default, non---prereleaseinstall) — there is no pipeline path from this input to a bare, non-prerelease0.x.y. Only a manually-published GitHub Release (publish-release.yaml, triggered byrelease: types: [published]— a human moving a draft release to live) produces a real, non-prerelease version; the identifier input has no bearing on that gate at all.
Decision Outcome¶
Lockstep versioning (Option 1), by explicit decision. Independent per-package versioning was rejected: it would require publishing a compatibility matrix (which Compono.XunitV3 versions work with which Compono core versions) that a five-package, single-maintainer preview has no real need for yet, and it would break the "bump all four versions together" practice cosmere-tracker already depends on today. One shared version number across Compono/Compono.XunitV3/Compono.NSubstitute/ Compono.Bogus (and Compono.Generators, versioned identically even though it's never independently referenced by a consumer — see "Package set" below) is the simplest statement of "these five packages are one coherent release," which is exactly what the ecosystem actually is during preview.
Rename alpha to preview (Option 2), by explicit decision. The release pipeline itself is unchanged — this is a one-line change to publish-preview.yaml's prereleaseIdentifier input, from alpha to preview. 0.x is already the correct, conventional SemVer signal that the API isn't yet stability-guaranteed; a separate -alpha suffix on top of it communicates nothing a consumer doesn't already know from the major version being 0, and it's what currently makes cosmere-tracker's Directory.Packages.props pin an alpha-suffixed version. preview is the more conventional label for a continuously- published prerelease stream and matches the workflow's own name (publish-preview.yaml). This belongs in Phase 0, not a later checkpoint — an earlier draft of this ADR mistakenly assumed dropping the identifier entirely would produce a bare, non-prerelease 0.x.y and sequenced the change at a release checkpoint to avoid publishing something that looked "done" too early. That assumption was wrong: every version publish-preview.yaml produces is a SemVer prerelease regardless of what the identifier string is (-alpha.N or -preview.N are both still prereleases, both still excluded from a plain dotnet add package install and both still visibly flagged as prerelease on nuget.org) — renaming the label carries no risk of a half-finished milestone looking publicly "done," so there's no reason to defer it. The actual "does this look done" gate is entirely separate: a human manually publishing a GitHub Release (publish-release.yaml, release: types: [published]), which this rename has no effect on either way — see PLAN-0008's Phase 8 for that gate.
Package set for the first preview¶
All five packages (Compono, Compono.Generators, Compono.XunitV3, Compono.NSubstitute, Compono.Bogus) ship in the first preview — docs/mvp.md's MVP package set is already complete and dogfooded end-to-end (Milestone 7). Compono.Generators is packed transitively inside Compono's own .nupkg (analyzers/dotnet/cs, ADR-0003) and is never independently referenced or independently versioned by a consumer — it still moves in lockstep with the other four for internal consistency, but has no separate NuGet listing of its own.
What would block a package from the first preview (none of the five are currently blocked, this is the standing bar for any future addition too): a failing build/test gate on the release pipeline; a missing required package-readiness item (see below); a known correctness bug with no workaround, per docs/adr/0029-...'s "Bug handling" precedent (fix it or hold the release, don't ship a known-broken package); or missing XML documentation on any public member (documentation.md's existing hard requirement — a public preview package without doc comments fails its own stated bar for discoverability before it fails anything external).
0.x compatibility policy¶
What a consumer may reasonably depend on during 0.x:
- A patch-version bump (
0.x.Y→0.x.Y+1) never contains a breaking API change — only bug fixes, documentation, or additive, backward- compatible surface. - A minor-version bump (
0.X.y→0.X+1.0) may contain a breaking change — during0.x, SemVer itself doesn't distinguish "breaking" at the minor level the way it does past 1.0 (where only a major bump may break), so Compono treats every0.xminor bump as a potential breaking-change boundary, always called out explicitly (see below). - All five packages at the same version number are the tested, guaranteed-compatible combination. Mixing version numbers across packages (e.g.
Compono 0.3.0withCompono.Bogus 0.2.0) is unsupported — lockstep versioning exists specifically so "install matching versions" is the entire compatibility rule a consumer needs to remember. - A generated composition plan (the primary execution path, ADR-0001) for a type that compiled and passed under one
0.xversion continues to behave identically under a later0.xpatch version, absent an explicitly documented behavior change.
How a breaking change is communicated, per bump:
- The PR/commit introducing it carries
.github/release-drafter.yml'sbreaking-changelabel. That label'sversion-resolvermapping must beminor, not release-drafter's configuredmajor, while Compono stays0.x— this is a deliberate override of the file's current mapping, not a restatement of it: today, abreaking-change- labeled PR resolves the next version as1.0.0, which would exit the0.xpreview line by accident rather than by the deliberate decision this ADR's own compatibility policy above requires. The label stays incategoriesunchanged (see point 2) — only itsversion-resolverbucket moves frommajortominor; see PLAN-0008 Phase 0 for the actual config change. - The generated GitHub Release notes carry an explicit "⚠️ Breaking Changes" section whenever a
breaking-change-labeled PR is included (release-drafter's existingcategoriesgrouping already does this — no template change needed). A release with no breaking-change-labeled PR simply has no such section; that absence is itself the signal that nothing broke, not a gap to fill with an empty section — release- drafter's category rendering is inherently conditional on the labels actually present, and this ADR doesn't ask for a template override to force an unconditional heading. docs/roadmap/index.md's "available today / experimental / planned" framing and the relevant Package Guide/Concepts page are updated in the same PR that ships the break, per this repo's existing "docs change in the same PR" rule.- A migration note is added to the affected page(s) when the break isn't self-explanatory from the API diff alone (e.g. a renamed method needs no note; a changed default behavior does).
Experimental vs. supported APIs. Everything shipped in a package's public surface is supported by default — Compono does not ship an [Experimental]-attributed API surface in the first preview (no such capability exists yet). If a future capability ships behind an opt-in experimental flag (per docs/documentation-architecture.md's Roadmap area, e.g. a future reflection-compatibility mode per ADR-0001's own Consequences section), it carries an explicit "Experimental" admonition in its documentation and is exempt from the patch-bump non-breaking guarantee above until it graduates — that policy is recorded here so a future ADR introducing such a capability doesn't need to re-litigate it.
Support policy for target frameworks and compiler/SDK versions. Compono targets net10.0;net11.0 today (Directory.Build.props/each package's .csproj) — net10.0 is the current GA release; net11.0 is the next release, tracked ahead of its own GA (global.json pins the 11.0.100-preview.6 SDK at time of writing, not a GA SDK). Policy: Compono supports the current GA .NET release plus the next release in development, tracked continuously through its preview SDKs into its own eventual GA — a rolling two-TFM window one release ahead, not one release behind. This is a deliberate choice to stay current with the platform, not a claim that net11.0 is itself GA-supported today; each Package Guide's metadata states plainly which of its two TFMs is GA and which is a preview build at the time a given version ships. The oldest TFM is dropped only on a minor-version bump (never silently in a patch). Compono.Generators targets netstandard2.0 (required for Roslyn analyzer/source-generator compatibility across host SDKs) — a separate, wider constraint, unrelated to the two-TFM consumer policy above, and already correct as shipped.
Package-readiness policy¶
The bar every package (Compono, Compono.Generators where applicable, Compono.XunitV3, Compono.NSubstitute, Compono.Bogus) must clear before the first preview publish, and every future package before it joins the set — stated as requirements, not a how-to. The concrete, executable checklist derived from this bar (specific MSBuild properties, CI steps, tool wiring) lives in PLAN-0008's "Package-readiness checklist," which Phase 0 executes — this ADR doesn't duplicate it.
- Complete discovery metadata. Package ID, title, description, tags, license, project/repository URL, icon, and embedded README — a stranger finding the package on nuget.org must be able to tell what it is and where it comes from without leaving the listing page. Whatever doesn't need to vary per package (license, repository URL, icon, README, tags, release notes) is defined once, centrally, and inherited by all five — the packages distribute as one coherent set (this ADR's lockstep versioning above), so their shared metadata comes from one place, not five copies that can quietly drift apart. Only genuinely per-package content (the ID itself, the title, and the description) lives in each project.
- Debuggable and verifiable provenance. Source Link, deterministic builds, and embedded symbols (a portable PDB embedded in the primary DLL is Compono's chosen shape — see PLAN-0008 for why this doesn't need a separate symbols package) — a consumer can step into Compono's source from their own debugger, and the published artifact is traceable back to the exact commit that built it.
- No dependency leakage. Build-only/analyzer-only dependencies (Roslyn packages, SourceLink) never flow to a consumer's own dependency tree;
Compono.Generators' analyzer packaging (ADR-0003) survives a real packed-consumer restore, not just aProjectReferencebuild. - Exact, tested dependency pins during
0.x. Compono's own dependencies stay pinned to exact versions (not ranges) while the ecosystem is young — "install the version we tested against," matching this ADR's own "install matching Compono versions" compatibility philosophy. This applies to two distinct dependency surfaces, both of which need fixing, not just one: Directory.Packages.props's current bare-version syntax (e.g.3.2.2) for Compono's own external dependencies (Bogus, NSubstitute, xUnit) doesn't actually enforce this — NuGet treats a bare version as a minimum-inclusive floor, not a hard pin.Compono.XunitV3/Compono.NSubstitute/Compono.Bogus's own dependency onComponoitself has the identical problem, from a different source: each referencesComponovia a plain<ProjectReference>, whichdotnet packconverts into a bare-version NuGet dependency on the referenced project's current version — the same minimum-inclusive floor, not lockstep's intended exact match. A consumer could installCompono.XunitV3 0.3.0alongside a newerCompono 0.5.0and have it restore successfully, exactly the "mixing version numbers across packages... is unsupported" scenario this ADR's compatibility policy declares unsupported above. Both need exact-pin syntax ([3.2.2]for the external case; an explicit override of theProjectReference-generated dependency range for the internal case) to make this bullet true rather than aspirational — see PLAN-0008 Phase 0 for both.<ProjectReference>itself stays — by explicit decision, the fix for the internal case only overrides the version range NuGet writes into the.nuspecat pack time, not the reference mechanism; each integration package keeps building againstCompono's live source for local development (fast inner loop, no local-feed round-trip to iterate on an integration package against a core change in the same repo) rather than switching to aPackageReference. Version ranges are a post-1.0 concern, once real consumer version-conflict evidence exists to justify them.- Automated package and API-compatibility validation, as an independent, locally-controlled CI gate — not inside the publish workflows. Both
publish-preview.yamlandpublish-release.yamlare a single job each, callinguses: LayeredCraft/devops-templates/.github/workflows/publish-*.yml— there is no step-level hook inside a reusable-workflow job for this repo to insert a "compute the baseline, then pack" sequence into, and there's no reason to ask the shareddevops-templatesworkflow to grow a new input just for this (that's a cross-repo change this ADR doesn't control and isn't asking for). Instead, package/API-compatibility validation runs as its own CI job in this repo — the same one Phase 0 already adds for pack/contents-inspection/local-feed verification, which this repo fully controls — as a pre-merge gate on every PR, not a step folded into either publish workflow: query nuget.org for each package's currently-latest published version, then rundotnet pack -p:PackageValidationBaselineVersion=<prior-version>for each of the four publishable packages and fail CI on an unreviewed incompatibility. Catching an accidental break before it merges tomainis strictly better than catching it after either workflow has already published it. A first-ever publish has nothing to query yet, so validation is inert for exactly that one case. A PR carrying thebreaking-changelabel skips this gate — that label already means the break is deliberate, permitted by this ADR's own0.X+1.0policy above, and communicated through release notes and docs (see "How a breaking change is communicated"); failing the same PR on the incompatibility the label itself declares would be a self-contradiction, not a real safety check. This requires the gate's own trigger to include label changes, not justpr-build.yaml's defaultpull_requestactivity types (opened/synchronize/reopened) —.github/workflows/release-drafter.yamlapplies thebreaking-changeautolabel in its own, separately-triggered workflow run, so a gate that only reads the label at the PR's initial push can evaluate before the label exists (blocking a legitimate break that hasn't been auto-labeled yet) or stay green after the label is later removed from the same commit (silently allowing a break whose "this is intentional" justification no longer applies). The gate must trigger onlabeled/unlabeledas well and read the label state current at its own run time, not a value cached from an earlier trigger. See PLAN-0008 Phase 0 for the CI implementation. - Verified against the packed artifact, not just a project reference. Every publishable package (
Compono,Compono.XunitV3,Compono.NSubstitute,Compono.Bogus) is restored and smoke-tested from a real local feed before it's trusted to publish — this repo's owntest/Compono.XunitV3.SampleTestsprecedent (PLAN-0004/0005/0006), extended to cover all four together as a standing gate.Compono.Generatorshas no independent.nupkgto restore (IsPackable=false— it's embedded insideCompono's own package, per ADR-0003); it's verified by inspectingCompono.nupkg'sanalyzers/dotnet/cscontents directly, not by a separate restore. - No known vulnerability, and no unreviewed license, in the dependency tree. These are two different checks, not one — Dependabot's existing security-update flow (
dependabot-auto-merge.yml) genuinely covers vulnerability alerts, but Dependabot never inspects a dependency's license; nothing in this repo's tooling does. Rather than claim license risk is "covered" by infrastructure that doesn't check it, the actual policy is a manual review step: any PR that adds a new dependency or changes aPackageVersion(Dependabot-authored or not) gets its target package's license checked as part of normal PR review, not a one-time snapshot at launch and never again. No new automated tool is introduced for a dependency set this small — this is a review habit, not a gate.
Provenance/signing and trusted publishing are already handled by the existing pipeline (NuGet.org's own signing infrastructure via the OIDC trusted-publishing flow already wired into nuget-push) and are not re-decided by this ADR.
Release pipeline: failure and partial-publish handling¶
Not redesigned — the existing two-workflow split (publish-preview.yaml/publish-release.yaml, both delegating to LayeredCraft/devops-templates) stays exactly as-is beyond the alpha→preview identifier rename above. Policy, recorded here because it governs what "safe to retry" means rather than describing a CI step: all four publishable packages are built and pushed from one coordinated lockstep version (Compono.Generators builds alongside them but has nothing of its own to push — it ships embedded inside Compono.nupkg), so a partial publish failure means a transient push error, not a version mismatch — the recovery procedure is simply re-running the push, and NuGet's own idempotent-upload behavior (same content, same version → no-op; different content, same version → rejected) makes that safe without a new rollback mechanism. If an already-published package is later found defective, the fix is always a new patch release (per SemVer — never unpublishing or overwriting a live version); NuGet's unlist (not delete) mechanism is available as a last resort for a genuinely broken package, used only alongside a same-day fixed release, never as a substitute for one.
Positive Consequences¶
- A consumer gets one version number to track and one compatibility rule ("matching versions across all five packages") instead of a compatibility matrix.
- The existing, working release pipeline and the existing
cosmere-trackerconsumption pattern both continue to work unmodified beyond thealpha→previewidentifier rename. - The package-readiness checklist gives Phase 0 of PLAN-0008 a concrete, verifiable bar instead of an open-ended "make packages good" task.
Negative Consequences¶
- Lockstep versioning means an unrelated single-package fix (e.g. a
Compono.Bogus-only bug) still bumps every package's version number, which can read as noisier release history than independent versioning would produce. Accepted: the simplicity and existing-practice continuity outweighs this for a five-package preview at this stage: a future ADR can revisit this once real multi-package-divergence evidence exists (e.g. packages actually needing different release cadences). - Treating every
0.xminor bump as a potential breaking-change boundary (rather than only communicating breaks "when they happen," at whatever version level) means a reader can never assume a minor bump is safe just from its version number alone — they have to check whether that specific release's notes carry a "⚠️ Breaking Changes" section (present only when abreaking-change-labeled PR is actually included, per "How a breaking change is communicated" above) rather than relying on SemVer's own past-1.0 convention that only a major bump can break. Accepted: the alternative (inconsistent signaling of which bumps might break) is worse for a preview trying to earn trust.
Pros and Cons of the Options¶
Lockstep versioning (chosen)¶
- Good, because it matches the already-working
cosmere-trackerconsumption pattern exactly. - Good, because "install matching versions" is the entire compatibility rule a consumer needs.
- Bad, because an unrelated single-package change still bumps every package's version.
Independent per-package versioning¶
- Good, because it avoids bumping untouched packages.
- Bad, because it requires publishing and maintaining a compatibility matrix this project has no current evidence it needs.
- Bad, because it breaks the existing
cosmere-trackerpinning practice.
Keep the alpha identifier¶
- Good, because it requires no pipeline change at all.
- Bad, because it's a less conventional label than
previewfor a continuously-published prerelease stream, and is the reasoncosmere-tracker'sDirectory.Packages.propscurrently pins an alpha-suffixed version.
Rename to preview (chosen)¶
- Good, because
previewis the more conventional label for this publishing stream, and matches the workflow's own name. - Good, because it's a one-line pipeline change, not a redesign.
- Good, because it carries no sequencing risk — every version this workflow produces stays a SemVer prerelease either way, so there's no reason to delay it behind a later checkpoint.
- Bad, because it still requires bumping
cosmere-tracker's pinned version once this ships, same as any other identifier change would.
Amendment 1 (2026-08-04): third-party dependencies use tested ranges, not exact pins¶
The "Exact, tested dependency pins during 0.x" bullet above, as originally written, applied one blanket exact-pin rule to two genuinely different dependency surfaces: (a) the internal lockstep relationship between Compono/Compono.XunitV3/Compono.NSubstitute/Compono.Bogus/ Compono.Generators, and (b) Compono's external third-party dependencies (Bogus, NSubstitute, xUnit, Microsoft libraries, Roslyn, Scriban). Collapsing both into "exact pins for everything" was a mistake surfaced during Phase 0 implementation, before either had actually shipped to a real consumer — this amendment corrects it before it does, rather than after.
What stays exactly as decided: the internal case. Compono.XunitV3/ Compono.NSubstitute/Compono.Bogus still declare an exact-pin ([x.y.z]) dependency on Compono's own version in their packed .nuspec — lockstep versioning (this ADR's Decision Outcome above) means "install matching versions across the Compono family" is the compatibility rule, and an exact pin is what actually enforces that rule rather than just stating it. This is unchanged.
What changes: Compono's external third-party dependencies no longer use blanket exact-pin syntax. An exact pin ([6.0.0]) forces every consumer of Compono.NSubstitute onto the identical NSubstitute patch version Compono itself built against, even when a newer compatible NSubstitute patch exists — a consumer already using a different NSubstitute/Bogus/xUnit version elsewhere in their project would be blocked from a compatible upgrade for no compatibility reason, which is a real consumer-experience cost this ADR's original text underweighted by treating "young ecosystem, stay pinned" as a one-size-fits-all default.
The corrected policy: Compono publishes intentional dependency constraints that represent the versions it actually supports, not a reflexive floor-or-exact choice:
- Establish a tested minimum version (the version actually built and tested against).
- Permit compatible updates within that dependency's supported major version — an exclusive upper bound at the next untested major (
[6.0.0, 7.0.0)forNSubstitute,[35.6.5, 36.0.0)forBogus,[3.2.2, 4.0.0)forxunit.v3.extensibility.core), not an open-ended floor and not a single exact version. - Exclude the next major version until it's actually been tested against — the exclusive upper bound, not a claim that the next major is known to be incompatible.
- Avoid unintentionally admitting an untested prerelease of the next major (NuGet's default stable-range matching already excludes prereleases outside an explicit prerelease floor, so this is a property to verify per dependency, not a syntax change on top of the range above).
- Narrower bounds only where real, specific compatibility evidence (a known break against a specific version, not a hypothetical one) justifies them.
This applies to dependencies that flow into a publishable package's own .nuspec (NSubstitute for Compono.NSubstitute, Bogus for Compono.Bogus, xunit.v3.extensibility.core for Compono.XunitV3) — the case that actually affects a consumer's install. It has no bearing on dependencies that never reach a consumer at all: Compono.Generators' Roslyn/Scriban/polyfill dependencies (PrivateAssets="all", and the project itself is IsPackable=false per ADR-0003) and the test-only tooling versions (Microsoft.Testing.Platform, xunit.v3.mtp-v2, etc.) that never appear in a shipped .nuspec at all — those stay whatever version this repo's own build/test needs, exact or not, since "genuinely required, private build-time tooling" was always the acknowledged exception, not a case this correction touches.
Package-readiness auditing (Phase 0) now includes, per publishable dependency that flows to a consumer: confirming the declared minimum is the version actually tested; confirming the upper bound excludes an untested next major; confirming no untested next-major prerelease is unintentionally admitted; and inspecting the generated .nuspec to confirm the range NuGet consumers actually receive matches what was intended — not just that some range was declared. See PLAN-0008 Phase 0 for the concrete task list this amendment revises.
Links¶
- ADR-0001 — source-generation-first default; this ADR's experimental-API carve-out references its own Consequences section
- ADR-0003 —
Compono.Generators' transitive packaging, verified (not redesigned) by this ADR's packed-consumer checklist item - ADR-0029 — "Bug handling" precedent this ADR's package-inclusion gate reuses
docs/migrating-from-autofixture.md— the existing "bump all four versions together" practice this ADR formalizes.github/workflows/publish-preview.yaml/publish-release.yaml— the unmodified release pipeline this ADR's policy governs- PLAN-0008 — Phase 0 executes this ADR's package-readiness checklist