Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,24 @@ categories:
- 'type: chore'
- 'type: ci'
- 'type: revert'
# ADR-0031's 0.X+1.0 compatibility policy for the 0.x preview line - a labeled
# breaking-change PR bumps minor, not major, so it never silently exits 0.x
# (PLAN-0008 Phase 0). Deliberately no 'major' version-resolver entry below -
# nothing in this repo auto-bumps major.
# ADR-0031 Amendment 5: Compono is ready to leave the 0.x preview line, so a
# labeled breaking-change PR resolves an ordinary SemVer major bump again,
# graduating to 1.0.0 (or beyond, for any later breaking change) whenever
# one next merges - not a statement that 1.0 has already shipped. The
# deliberate minor-bump override ADR-0031's original "0.x compatibility
# policy" put in place (PLAN-0008 Phase 0) applied only while intentionally
# staying on 0.x, and no longer applies now that that decision has been
# made.
- type: 'version-resolver'
semver-increment: 'major'
when:
labels:
- 'breaking-change'
- type: 'version-resolver'
semver-increment: 'minor'
when:
labels:
- 'type: feat'
- 'breaking-change'
- type: 'version-resolver'
semver-increment: 'patch'
when:
Expand Down
188 changes: 188 additions & 0 deletions .github/workflows/aot-validation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
name: AOT Validation

# ADR-0041 Amendment 7: permanent, CI-blocking Native AOT smoke gate for the eight existing
# test/*.AotSmokeTest projects, replacing the previous manual-only "run it by hand before release"
# verification.
#
# Deliberately NO `paths:` filter on this workflow's own `pull_request` trigger. A workflow entirely
# skipped by trigger-level path filtering leaves its required status check `Pending` rather than
# reporting success, under GitHub's required-check semantics - that would block a PR indefinitely
# instead of passing it on an AOT-irrelevant change. Selectivity happens *inside* the workflow
# instead: the `changes` job below computes which of the eight legs are actually applicable from the
# PR's changed files (a small repository-owned `git diff` script, not a third-party changed-files
# action), each leg's own publish-and-run job runs behind an `if:` reading that output (an
# inapplicable leg reports an ordinary skipped conclusion, never a missing status), and `aot-gate` -
# the one job branch protection should name as the required check - always runs
# (`if: always()`) and always resolves to success or failure, so the required check never sits
# Pending.
#
# The exact guarantee this gate proves: the packaged Compono package's exercised public API surface
# is callable from a Native-AOT-published, trimmed consumer application without runtime AOT/trimming
# failures. This is deliberately narrower than "the package's public API surface": it covers core
# `Compono` itself, not only integration packages, and it makes no claim of exhaustive coverage of
# every public member - each leg is a throwaway console app calling a specific, real entry point of
# the packaged API directly. This gate makes NO claim that xUnit v3, TUnit, MSTest, or NUnit's own
# test runners are Native-AOT compatible - no leg ever publishes a test framework's runner/host as
# Native AOT.
#
# This repository does not use GitHub merge queues today - no `merge_group` trigger is added
# speculatively; add one later only if that changes.

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
changes:
runs-on: ubuntu-latest
outputs:
legs: ${{ steps.compute.outputs.legs }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Determine applicable AOT smoke legs
id: compute
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
changed=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
echo "Changed files:"
echo "$changed"

all_legs='["Compono","Compono.Http","Compono.Logging","Compono.MSTest","Compono.NUnit","Compono.TestDoubles","Compono.TUnit","Compono.XunitV3"]'

# A change to shared/core/generator infrastructure (or to this workflow itself) can affect
# every packaged leg at once - run all eight rather than reflexively narrowing to only the
# paths that happened to change (ADR-0041 Amendment 7's own "do not run it reflexively, but
# do not under-run it either" balance).
if echo "$changed" | grep -qE '^(src/Compono/|src/Compono\.Generators/|Directory\.Packages\.props|Directory\.Build\.(props|targets)|test/Directory\.Build\.(props|targets)|\.github/workflows/aot-validation\.yaml)'; then
echo "Core/generator/shared-config change detected - running all eight legs."
echo "legs=$all_legs" >> "$GITHUB_OUTPUT"
exit 0
fi

legs="[]"
add_leg() {
legs=$(echo "$legs" | jq -c --arg leg "$1" '. + [$leg] | unique')
}

echo "$changed" | grep -q '^src/Compono\.Http/' && add_leg "Compono.Http"
echo "$changed" | grep -q '^src/Compono\.Logging/' && add_leg "Compono.Logging"
echo "$changed" | grep -q '^src/Compono\.MSTest/' && add_leg "Compono.MSTest"
echo "$changed" | grep -q '^src/Compono\.NUnit/' && add_leg "Compono.NUnit"
echo "$changed" | grep -q '^src/Compono\.TestDoubles/' && add_leg "Compono.TestDoubles"
echo "$changed" | grep -q '^src/Compono\.TUnit/' && add_leg "Compono.TUnit"
echo "$changed" | grep -q '^src/Compono\.XunitV3/' && add_leg "Compono.XunitV3"

# A change scoped to one leg's own AotSmokeTest project only needs that leg re-run, not all
# eight - extract which leg(s) directly from the changed paths.
for proj in $(echo "$changed" | grep -oE '^test/[^/]+\.AotSmokeTest/' | sed -E 's#^test/(.+)\.AotSmokeTest/#\1#' | sort -u); do
add_leg "$proj"
done

echo "Applicable legs: $legs"
echo "legs=$legs" >> "$GITHUB_OUTPUT"

aot-smoke:
needs: changes
if: needs.changes.outputs.legs != '[]'
strategy:
fail-fast: false
matrix:
leg: ${{ fromJSON(needs.changes.outputs.legs) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-dotnet@v6
with:
# global.json pins a preview 11.0.x SDK for this repo; setup-dotnet must install every SDK
# major version pr-build.yaml/package-validation.yaml already do, even though every
# AotSmokeTest project itself only targets net10.0 - otherwise `dotnet` CLI resolution
# fails for the *whole checkout* the moment global.json's pinned SDK isn't present, not
# just for a net11.0-specific build.
dotnet-version: |
8.0.x
9.0.x
10.0.x
11.0.x

- name: Pack ${{ matrix.leg }} into its local AOT smoke feed
run: bash "test/${{ matrix.leg }}.AotSmokeTest/pack-compono.sh"

- name: Publish ${{ matrix.leg }} smoke consumer as Native AOT
run: |
dotnet publish "test/${{ matrix.leg }}.AotSmokeTest" \
-c Release -f net10.0 -r linux-x64 --self-contained true \
-p:PublishAot=true \
-o "test/${{ matrix.leg }}.AotSmokeTest/bin/aot-publish"

- name: Run the published Native AOT binary
run: "./test/${{ matrix.leg }}.AotSmokeTest/bin/aot-publish/${{ matrix.leg }}.AotSmokeTest"

# Outlier per-leg additional proofs (PLAN-0061 Phase 1) - beyond the ordinary publish-and-run
# pattern above, these two packages carry their own extra packaging-content verification.
- name: Additional packaging verification (Compono.Logging)
if: matrix.leg == 'Compono.Logging'
run: bash test/Compono.Logging.AotSmokeTest/verify-packaging.sh

- name: Additional analyzer-contract verification (Compono.Http)
if: matrix.leg == 'Compono.Http'
run: bash test/Compono.Http.AotSmokeTest/AnalyzerContract/verify-analyzer-contract.sh

aot-gate:
needs: [changes, aot-smoke]
if: always()
runs-on: ubuntu-latest
steps:
# Fail closed, not just on an applicable leg's own failure: this job runs via `if: always()`
# specifically so it never sits Pending, but that same `always()` means it also runs when
# `changes` itself failed/was cancelled (checkout, `git diff`, or the applicability script
# erroring) - `aot-smoke` reports `skipped` in that case too (its own `if:` implicitly
# requires `needs.changes` to have succeeded), which the original version of this check could
# not tell apart from the legitimate "changes succeeded, zero legs applicable" skip. Both
# produce `aot-smoke.result == skipped`; only `changes.result` distinguishes them. Checked
# first, before anything else, so every other branch below can assume applicability was
# actually computed.
- name: Confirm applicability was computed and every applicable leg passed
run: |
changes_result="${{ needs.changes.result }}"
smoke_result="${{ needs.aot-smoke.result }}"
legs='${{ needs.changes.outputs.legs }}'

if [ "$changes_result" != "success" ]; then
echo "::error::Applicability computation ('changes' job) did not succeed (result: $changes_result) - failing closed rather than trusting an unproven leg set." >&2
exit 1
fi

if [ "$smoke_result" = "failure" ] || [ "$smoke_result" = "cancelled" ]; then
echo "::error::One or more applicable AOT smoke legs failed or were cancelled - see the aot-smoke job above." >&2
exit 1
fi

if [ "$smoke_result" = "skipped" ]; then
# A legitimate skip only ever means "changes succeeded and found zero applicable legs" -
# aot-smoke's own `if: needs.changes.outputs.legs != '[]'` is the only thing that skips
# it when changes succeeded. Confirm that's really why, rather than trusting the skip
# blindly now that changes_result == success is already known.
if [ "$legs" != "[]" ]; then
echo "::error::aot-smoke was skipped but changes reported applicable legs ($legs) - this should never happen; failing closed." >&2
exit 1
fi
echo "No applicable AOT smoke legs for this change - gate passes."
exit 0
fi

if [ "$smoke_result" != "success" ]; then
echo "::error::Unexpected aot-smoke result: $smoke_result - failing closed." >&2
exit 1
fi

echo "AOT validation gate passed. Applicable legs: $legs"
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
- name: Fail on API reference drift
run: |
if ! git diff --exit-code -- docs/reference/api; then
echo "::error::docs/reference/api is out of date. Run '.github/scripts/generate-api-reference.sh' (after building the eight publishable packages for net10.0 Release) and commit the result." >&2
echo "::error::docs/reference/api is out of date. Run '.github/scripts/generate-api-reference.sh' (after building the publishable packages for net10.0 Release) and commit the result." >&2
exit 1
fi

Expand Down
38 changes: 14 additions & 24 deletions .github/workflows/package-validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ jobs:
# CS1591 enforcement.
BREAKING_CHANGE: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change') }}
PACK_OUTPUT: ${{ github.workspace }}/artifacts/package-validation
# Single authoritative publishable-package list (PLAN-0061 Phase 1) - the baseline-lookup,
# pack, and CS1591-enforcement steps below all derive from this one job-level env var instead
# of each repeating the same 11-package literal, so adding/removing a package can't drift
# across the three independently. A job-level `env:` entry is injected into every step's own
# process environment by GitHub Actions itself, so this survives across the separate `run:`
# steps below (each its own shell process) with no extra plumbing - deliberately not a Bash
# array, which would only live for the one step that declared it.
PACKAGES: "Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit"
steps:
- uses: actions/checkout@v7

Expand All @@ -47,7 +55,7 @@ jobs:
- name: Resolve nuget.org baseline versions
run: |
set -euo pipefail
for pkg in Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit; do
for pkg in $PACKAGES; do
id_lower=$(echo "$pkg" | tr '[:upper:]' '[:lower:]')
# No -f here: a non-2xx response must still reach the status-code branch
# below (curl -f suppresses the body/short-circuits on HTTP errors), and
Expand Down Expand Up @@ -96,33 +104,15 @@ jobs:
dotnet pack "$csproj" -c Release -o "$PACK_OUTPUT" "${args[@]}"
}

pack_one src/Compono/Compono.csproj BASELINE_Compono
pack_one src/Compono.XunitV3/Compono.XunitV3.csproj BASELINE_Compono_XunitV3
pack_one src/Compono.NSubstitute/Compono.NSubstitute.csproj BASELINE_Compono_NSubstitute
pack_one src/Compono.Bogus/Compono.Bogus.csproj BASELINE_Compono_Bogus
pack_one src/Compono.TUnit/Compono.TUnit.csproj BASELINE_Compono_TUnit
pack_one src/Compono.TestDoubles/Compono.TestDoubles.csproj BASELINE_Compono_TestDoubles
pack_one src/Compono.DependencyInjection/Compono.DependencyInjection.csproj BASELINE_Compono_DependencyInjection
pack_one src/Compono.Http/Compono.Http.csproj BASELINE_Compono_Http
pack_one src/Compono.Logging/Compono.Logging.csproj BASELINE_Compono_Logging
pack_one src/Compono.MSTest/Compono.MSTest.csproj BASELINE_Compono_MSTest
pack_one src/Compono.NUnit/Compono.NUnit.csproj BASELINE_Compono_NUnit
for pkg in $PACKAGES; do
pack_one "src/$pkg/$pkg.csproj" "BASELINE_$(echo "$pkg" | tr '.' '_')"
done

- name: Enforce XML doc comments (CS1591) on publishable packages
run: |
set -euo pipefail
for csproj in \
src/Compono/Compono.csproj \
src/Compono.XunitV3/Compono.XunitV3.csproj \
src/Compono.NSubstitute/Compono.NSubstitute.csproj \
src/Compono.Bogus/Compono.Bogus.csproj \
src/Compono.TUnit/Compono.TUnit.csproj \
src/Compono.TestDoubles/Compono.TestDoubles.csproj \
src/Compono.DependencyInjection/Compono.DependencyInjection.csproj \
src/Compono.Http/Compono.Http.csproj \
src/Compono.Logging/Compono.Logging.csproj \
src/Compono.MSTest/Compono.MSTest.csproj \
src/Compono.NUnit/Compono.NUnit.csproj; do
for pkg in $PACKAGES; do
csproj="src/$pkg/$pkg.csproj"
echo "Building $csproj with CS1591 as an error"
dotnet build "$csproj" -c Release -p:WarningsAsErrors=CS1591
done
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0031-public-preview-release-and-versioning-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,42 @@ upper bounds, and package/dependency mapping are all unchanged.
unmodified release pipeline this ADR's policy governs
- [PLAN-0008](../plans/0008-milestone-8-public-preview.md) — Phase 0
executes this ADR's package-readiness checklist

## Amendment 5 (2026-09-03): `breaking-change` maps to `major` again — the `0.x` compatibility policy's own deliberate override no longer applies

This ADR's "`0.x` compatibility policy" section (above) deliberately
overrode `.github/release-drafter.yml`'s ordinary `breaking-change` →
`major` mapping down to `minor`, specifically so a breaking-change-labeled
PR could not "exit the `0.x` preview line by accident rather than by the
deliberate decision this ADR's own compatibility policy... requires." That
text, and the reasoning behind it, is left exactly as written — this
amendment records that the deliberate decision it was guarding against has
now actually been made: the product owner has confirmed Compono is ready to
leave the `0.x` preview line via [PLAN-0061](../plans/0061-pre-1-0-cleanup-and-consolidation.md)'s
pre-1.0 cleanup gate.

**Decision:** `.github/release-drafter.yml`'s `version-resolver` reverts to
its ordinary, un-overridden mapping — `breaking-change` → `major`,
`type: feat` → `minor` (previously combined into one `minor` bucket covering
both labels; split into two buckets so a breaking change and an ordinary
feature addition no longer resolve to the same increment), everything else
unchanged (`patch` for fix/docs/refactor/test/chore/ci/revert, and the
existing bare `patch` fallback). This is the exact reversal this ADR's own
"How a breaking change is communicated" section already named as the
intentional graduation path — not a new mechanism, not a redesign of the
labeling/categories scheme, which stays exactly as this ADR and PLAN-0008
Phase 0 established it.

**Consequence, stated plainly per this ADR's own "no undefined 0.x" driver:**
the next `breaking-change`-labeled PR resolves the next version as a real
major bump (`1.0.0`, or beyond it if further breaking changes land after
that), not another `0.X+1.0` minor bump. The `0.x` compatibility policy
section above describes `0.x`'s own compatibility contract accurately for
every release that shipped under it — it does not retroactively change once
Compono leaves `0.x`, per this repo's own "an ADR's original text stays
exactly as written" rule; a reader of a historical `0.x` release still finds
the policy that actually governed it.

No other part of this ADR changes: lockstep versioning, the packaged-consumer
readiness checklist, the dependency-range policy (Amendments 1/4), and the
TFM-window policy are all unaffected by leaving `0.x`.
Loading
Loading