diff --git a/.github/actions/purview-build/action.yml b/.github/actions/purview-build/action.yml index d86e80f..fb20b3d 100644 --- a/.github/actions/purview-build/action.yml +++ b/.github/actions/purview-build/action.yml @@ -16,6 +16,11 @@ inputs: description: .NET SDK used by the consuming repository. required: false default: 10.0.x + config-path: + description: >- + Explicit purview-build.json location (PURVIEW_BUILD_CONFIG). When omitted, the tool uses its + documented probe order starting at the repository root. + required: false runs: using: composite @@ -38,6 +43,12 @@ runs: - name: Run shared build shell: bash - run: "${{ runner.temp }}/purview-build/purview-build" + run: | + # Only forward config-path when the caller provided it: an empty PURVIEW_BUILD_CONFIG + # would be an explicit-but-missing path, which the tool treats as an error. + if [ -n "${{ inputs.config-path }}" ]; then + export PURVIEW_BUILD_CONFIG="${{ inputs.config-path }}" + fi + "${{ runner.temp }}/purview-build/purview-build" env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/purview-build.yml b/.github/workflows/purview-build.yml index 718c8b6..012256d 100644 --- a/.github/workflows/purview-build.yml +++ b/.github/workflows/purview-build.yml @@ -47,9 +47,20 @@ on: description: Comma-separated test project names/globs to run (Build__TestProjects). required: false type: string + config-path: + description: >- + Explicit purview-build.json location (PURVIEW_BUILD_CONFIG). When omitted, the tool uses + its documented probe order starting at the repository root. + required: false + type: string secrets: NUGET_APIKEY: required: false + # Declared because consuming repositories historically store the key under this name and the + # job forwards it as NUGET_API_KEY. A secret referenced but not declared here resolves only + # under `secrets: inherit`; declaring it makes the contract explicit either way. + NUGET__APIKEY: + required: false permissions: contents: read @@ -103,4 +114,7 @@ jobs: if [ -n "${{ inputs.test-projects }}" ]; then export Build__TestProjects="${{ inputs.test-projects }}" fi + if [ -n "${{ inputs.config-path }}" ]; then + export PURVIEW_BUILD_CONFIG="${{ inputs.config-path }}" + fi "${{ runner.temp }}/purview-build/purview-build" diff --git a/.github/workflows/purview-release.yml b/.github/workflows/purview-release.yml index 5243dd6..b3dbcfb 100644 --- a/.github/workflows/purview-release.yml +++ b/.github/workflows/purview-release.yml @@ -26,6 +26,26 @@ on: required: false default: main type: string + config-path: + description: >- + Explicit purview-build.json location (PURVIEW_BUILD_CONFIG). When omitted, the tool uses + its documented probe order starting at the repository root. + required: false + type: string + eligibility-policy: + description: >- + Release eligibility policy to evaluate (Release__Eligibility__Policy), e.g. + TrunkReservesMinor. When omitted, the repository's own configuration decides. + required: false + type: string + release-channel: + description: Named release channel (Release__Channel), e.g. stable or preview. + required: false + type: string + version-source: + description: Where the version comes from (Version__Source). Currently PackageJson. + required: false + type: string trusted-publishing: description: Use NuGet Trusted Publishing (no API key) instead of an API key. required: false @@ -63,6 +83,11 @@ on: secrets: NUGET_APIKEY: required: false + # Declared because consuming repositories historically store the key under this name and the + # job forwards it as NUGET_API_KEY. A secret referenced but not declared here resolves only + # under `secrets: inherit`; declaring it makes the contract explicit either way. + NUGET__APIKEY: + required: false permissions: contents: write @@ -73,9 +98,24 @@ jobs: name: Release runs-on: ubuntu-latest timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ github.token }} + # Consumer repos historically store the key under NUGET__APIKEY; some use NUGET_APIKEY. + # The tool reads the plain process env vars NUGET_APIKEY / NUGET_API_KEY. + NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }} + NUGET_API_KEY: ${{ secrets.NUGET__APIKEY }} + Release__Mode: ${{ inputs.release-mode }} + Release__UploadArtifacts: ${{ inputs.upload-artifacts }} + NuGet__TrustedPublishing: ${{ inputs.trusted-publishing }} + Build__RunTests: ${{ inputs.run-tests }} + Build__RunLint: ${{ inputs.run-lint }} + Build__RunPack: ${{ inputs.run-pack }} + Build__ValidatePack: ${{ inputs.validate-pack }} steps: - uses: actions/checkout@v7 with: + # Eligibility is judged against the tags that already exist, so the full tag list + # has to be present before the tool evaluates anything. fetch-depth: 0 fetch-tags: true @@ -85,24 +125,29 @@ jobs: - uses: oven-sh/setup-bun@v2 - - name: Check for version bump - id: version + - name: Forward optional inputs shell: bash run: | - VERSION=$(bun -p "require('./package.json').version") - TAG="v$VERSION" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Version $VERSION is already tagged as $TAG. Skipping release." - echo "should_release=false" >> "$GITHUB_OUTPUT" - else - echo "New version $VERSION detected. Releasing $TAG." - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Only forward optional settings the caller actually provided. Setting any of these to + # the empty string would override the consuming repo's purview-build.json, because + # environment variables take precedence over the JSON config (see commit 4d72bf7). + if [ -n "${{ inputs.config-path }}" ]; then + echo "PURVIEW_BUILD_CONFIG=${{ inputs.config-path }}" >> "$GITHUB_ENV" + fi + if [ -n "${{ inputs.eligibility-policy }}" ]; then + echo "Release__Eligibility__Policy=${{ inputs.eligibility-policy }}" >> "$GITHUB_ENV" + fi + if [ -n "${{ inputs.release-channel }}" ]; then + echo "Release__Channel=${{ inputs.release-channel }}" >> "$GITHUB_ENV" + fi + if [ -n "${{ inputs.version-source }}" ]; then + echo "Version__Source=${{ inputs.version-source }}" >> "$GITHUB_ENV" + fi + if [ -n "${{ inputs.test-filter }}" ]; then + echo "Build__TestFilter=${{ inputs.test-filter }}" >> "$GITHUB_ENV" fi - name: Install shared build - if: steps.version.outputs.should_release == 'true' shell: bash run: | VERSION_ARGS="" @@ -111,26 +156,42 @@ jobs: fi dotnet tool install Purview.Build --tool-path "${{ runner.temp }}/purview-build" $VERSION_ARGS + - name: Evaluate release eligibility + id: eligibility + shell: bash + run: | + # The tool evaluates; this workflow decides. release-explain runs no module, mutates + # nothing, and always exits 0 — the decision is in its JSON. + "${{ runner.temp }}/purview-build/purview-build" release-explain --format=json > explain.json + cat explain.json + + { + echo "verdict=$(jq -r '.verdict' explain.json)" + echo "release_mode=$(jq -r '.releaseMode' explain.json)" + echo "decided_by=$(jq -r '.decidedByRule // ""' explain.json)" + } >> "$GITHUB_OUTPUT" + + jq -r '.message' explain.json > eligibility-message.txt + + - name: Report ineligible release + if: steps.eligibility.outputs.verdict == 'Fail' + shell: bash + run: | + echo "::error title=Release not eligible::$(cat eligibility-message.txt)" + exit 1 + + - name: Skip release + if: steps.eligibility.outputs.verdict == 'Skip' + shell: bash + run: | + echo "::notice title=Release skipped::$(cat eligibility-message.txt)" + - name: Run release pipeline - if: steps.version.outputs.should_release == 'true' + if: steps.eligibility.outputs.verdict == 'Release' + shell: bash env: - GITHUB_TOKEN: ${{ github.token }} - # Consumer repos historically store the key under NUGET__APIKEY; some use NUGET_APIKEY. - # The tool reads the plain process env vars NUGET_APIKEY / NUGET_API_KEY. - NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }} - NUGET_API_KEY: ${{ secrets.NUGET__APIKEY }} - Release__Mode: ${{ inputs.release-mode }} - Release__UploadArtifacts: ${{ inputs.upload-artifacts }} - NuGet__TrustedPublishing: ${{ inputs.trusted-publishing }} - Build__RunTests: ${{ inputs.run-tests }} - Build__RunLint: ${{ inputs.run-lint }} - Build__RunPack: ${{ inputs.run-pack }} - Build__ValidatePack: ${{ inputs.validate-pack }} + # The evaluated decision, not the raw input: a verdict that is not "Release" resolves to + # None, so the publish and release modules skip even if this step were reached. + Release__Mode: ${{ steps.eligibility.outputs.release_mode }} run: | - # Only forward the optional test filter when the caller provides it. - # Setting it to empty would override a consuming repo's purview-build.json - # (env vars take precedence over the JSON config) and silently disable the filter. - if [ -n "${{ inputs.test-filter }}" ]; then - export Build__TestFilter="${{ inputs.test-filter }}" - fi "${{ runner.temp }}/purview-build/purview-build" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8679587..5634af4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,35 +31,22 @@ jobs: - uses: oven-sh/setup-bun@v2 - - name: Read and validate release version + # Irreducible: the tool cannot read its own version for `dotnet pack` before it has been + # packed. Eligibility is NOT decided here — that is the tool's job, below. + - name: Read release version id: version shell: bash run: | VERSION=$(bun -p "require('./package.json').version") - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "package.json version '$VERSION' is not SemVer." >&2 - exit 1 - fi - TAG="v$VERSION" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Version $VERSION is already released as $TAG." - echo "release=false" >> "$GITHUB_OUTPUT" - else - echo "release=true" >> "$GITHUB_OUTPUT" - fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Restore - if: steps.version.outputs.release == 'true' run: dotnet restore src/Build.slnx - name: Build gate - if: steps.version.outputs.release == 'true' run: dotnet build src/Build.slnx --configuration Release --no-restore --warnaserror - name: Pack tool from source - if: steps.version.outputs.release == 'true' run: >- dotnet pack src/Build.slnx --configuration Release --no-build --output artifacts @@ -68,7 +55,6 @@ jobs: -p:PackageVersion=${{ steps.version.outputs.version }} - name: Verify NuGet API key - if: steps.version.outputs.release == 'true' env: NUGET_APIKEY: ${{ secrets.NUGET__APIKEY }} run: | @@ -78,19 +64,43 @@ jobs: fi - name: Install packed tool - if: steps.version.outputs.release == 'true' run: >- dotnet tool install Purview.Build --tool-path "$RUNNER_TEMP/purview-build" --add-source artifacts --version "${{ steps.version.outputs.version }}" + - name: Evaluate release eligibility + id: eligibility + env: + Release__Mode: NuGet + run: | + "$RUNNER_TEMP/purview-build/purview-build" release-explain --format=json > explain.json + cat explain.json + + { + echo "verdict=$(jq -r '.verdict' explain.json)" + echo "release_mode=$(jq -r '.releaseMode' explain.json)" + } >> "$GITHUB_OUTPUT" + + jq -r '.message' explain.json > eligibility-message.txt + + - name: Report ineligible release + if: steps.eligibility.outputs.verdict == 'Fail' + run: | + echo "::error title=Release not eligible::$(cat eligibility-message.txt)" + exit 1 + + - name: Skip release + if: steps.eligibility.outputs.verdict == 'Skip' + run: echo "::notice title=Release skipped::$(cat eligibility-message.txt)" + - name: Run release pipeline (builds, publishes and tags itself) - if: steps.version.outputs.release == 'true' + if: steps.eligibility.outputs.verdict == 'Release' env: GITHUB_TOKEN: ${{ github.token }} NUGET_APIKEY: ${{ secrets.NUGET__APIKEY }} - Release__Mode: NuGet + Release__Mode: ${{ steps.eligibility.outputs.release_mode }} Release__UploadArtifacts: "true" NuGet__FeedUrl: https://api.nuget.org/v3/index.json Build__RunTests: "false" diff --git a/.gitignore b/.gitignore index a047b9b..fbd4f33 100644 --- a/.gitignore +++ b/.gitignore @@ -662,3 +662,8 @@ BenchmarkDotNet.Artifacts/ !build/ .tools/ + +# Throwaway repositories created by the release/config scenario matrices +.scenario-runs/ + +!src/src/Build/Release diff --git a/AGENTS.md b/AGENTS.md index d4c3bc6..50d6a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,13 +8,18 @@ This file is the primary instruction set for human and AI agents working in this ## Repository layout -- `src/Purview.Build/` — the dotnet tool (the pipeline): `Program.cs`, `Modules/`, `Settings/`, `Helpers/`, `appsettings.json` (tool defaults). +- `src/src/Build/` — the dotnet tool (the pipeline, package `Purview.Build`): `Program.cs`, `Modules/`, `Settings/`, `Helpers/`, `Configuration/`, `Release/`, `Version/`, `appsettings.json` (tool defaults). +- `src/tests/Build.UnitTests/` and `src/tests/Build.IntegrationTests/` — the test suites. TUnit categories come from the project-name suffix (the Purview SDK emits an assembly-level `[Category]`), so **never write a `[Category]` attribute by hand**: put a test in the project whose name carries the category you want. +- `src/tests/fixtures/` — the declarative scenario matrices (`eligibility-scenarios.json`, `config-resolution-scenarios.json`) and the `release-explain` golden file. These drive both the TUnit suites and the `just` CLI matrices; a new rule or probe location means appending cases here, not writing test code. +- `src/tests/scenarios/` — the shell runners behind `just release-matrix` / `just config-matrix`. - `.github/actions/purview-build/` and `.github/workflows/` — the shared action and reusable workflows. - `.github/workflows/ci.yml` and `release.yml` — this repository's own CI/CD, which dogfoods the tool. - `docs/wiki/` — authoritative documentation: - `docs/wiki/Architecture.md` — architecture and design decisions. - `docs/wiki/Configuration-Reference.md` — the full configuration reference (settings keys, defaults, modules, release behavior). - `docs/wiki/Release-Flow.md` — versioning, branch models, and release strategy. + - `docs/wiki/Release-Models.md` — the three release trigger models, as raw Mermaid source. + - `docs/wiki/Local-Development.md` — `just` recipes, `release-explain`, simulation, and the local rehearsal. - `docs/wiki/Migration-*.md` — per-consumer migration guides. - `README.md` — user-facing overview and minimal consumer setup. - `purview-build.json` — this repository's own pipeline configuration. @@ -25,9 +30,14 @@ This file is the primary instruction set for human and AI agents working in this The developer entry points are the `just` recipes; the .NET SDK and `just` are required. - `just build` — compile `src/Purview.Build` (Debug). -- `just test` / `just test-unit` — run tests under `src/tests` (this repo has no tests yet). +- `just test` / `just test-unit` — run tests under `src/tests`. CI runs the unit filter only (`Build:TestFilter` in `purview-build.json`); `just test` runs everything. +- `just test-eligibility` — the eligibility and config-resolution suites only. +- `just release-matrix` / `just config-matrix` — run every scenario through the real CLI. **Run both before any change to release or configuration behaviour**; they need no secrets and no network. +- `just release-explain` / `just release-simulate ` — explain the release decision, mutating nothing. +- `just lint-yaml` — `actionlint` over the workflow files (skipped with a note when not installed). - `just lint-check` / `just lint-fix` — CSharpier (via local tool manifest `.config/dotnet-tools.json`). - `just pipeline-pr` / `just pipeline-build` / `just pipeline-release` / `just pipeline-tests` — run the shared tool itself. +- `just pipeline-dogfood` — pack the tool from source, install it to a temp tool path, and run it against this repository. Use this rather than running the `bin/` binary against a Release build of itself, which fails on a locked file. - `just clean-all` / `just scrub` — clean build outputs. CI builds with `--warnaserror`. @@ -42,7 +52,13 @@ CI builds with `--warnaserror`. ## Rules and invariants -- **Versioning**: the single source of truth is the `version` field in `package.json`. The pipeline reads it (`VersionModule`); `PackModule` overrides `Version`/`PackageVersion` from it. +- **Versioning**: the single source of truth is the `version` field in `package.json`. `VersionModule` resolves it once into an ordered set of release units; every downstream module reads that result and **never recomputes a version**. `Version:Strictness` defaults to `NuGet`, which accepts four-part versions — two consuming repositories ship them, so do not tighten this default. +- **Eligibility ownership**: the tool *evaluates* release eligibility and reports it; the workflow *decides* and sets `Release__Mode`. The tool must never silently decline to release when asked to. `ReportEligibilityModule` is category `Build` and every failure path in it is a warning. +- **Rule IDs are permanent**: never renumber or repurpose a `REL0nn`. +- **Do not add a `concurrency` block to `purview-release.yml`**: a shared group between caller and reusable workflow makes GitHub cancel the run as a deadlock. Callers own release serialization. +- **Do not remove the `release-branch` input** from `purview-release.yml`: all seven consuming repositories still pass it. +- **Optional workflow inputs are forwarded only when non-empty.** Environment variables outrank `purview-build.json`, so forwarding an unset input as the empty string erases a consumer's configured value. `WorkflowParityTests` enforces this. +- **CLI output must not be reflowed.** `CLIConsole` lifts the Spectre profile width on every write: at the default 80 columns, redirected output gets newlines inserted inside JSON strings and diagnostics, which breaks `jq` and `grep`. - **Do not push release tags manually.** The tool owns tagging (`v{version}`) and the GitHub release (`CreateGitHubReleaseModule`); releasing = bump `package.json` and merge. See `docs/wiki/Release-Flow.md`. - **Secrets**: supplied at runtime via env vars / CI secrets (`NUGET_APIKEY`/`NUGET_API_KEY`, `GITHUB_TOKEN`, `LOCAL_NUGET_FEED_PATH`). Never hardcode or commit them. - **Configuration precedence**: command line > environment variables (nested keys use `__`) > `purview-build.json` > baked-in defaults (`appsettings.json`). @@ -56,4 +72,14 @@ CI builds with `--warnaserror`. ## Documentation -Keep `README.md`, `docs/wiki/Architecture.md`, `docs/wiki/Configuration-Reference.md`, and the workflow/action inputs in sync when changing behavior. If a new setting or default is added, it must be reflected in the `docs/wiki/Configuration-Reference.md` tables and in `src/src/Build/appsettings.json` defaults. \ No newline at end of file +Keep `README.md`, `docs/wiki/Architecture.md`, `docs/wiki/Configuration-Reference.md`, and the workflow/action inputs in sync when changing behavior. If a new setting or default is added, it must be reflected in the `docs/wiki/Configuration-Reference.md` tables and in `src/src/Build/appsettings.json` defaults. + +### Consuming repositories' own docs + +`purview.dev` is generated from each repository's `docs/wiki/`, so a behaviour change here can leave a consumer's page stating something the pipeline no longer does. Those pages live in the consuming repositories, not in this one, and are fixed there — the portal picks the correction up on release. + +When changing release behaviour, grep the consuming repositories' `docs/wiki/Release-Flow.md` for claims about the shared pipeline. Three claims that were wrong and have been corrected, as examples of the kind to look for: + +- "The workflow does not publish to NuGet" — `release-mode: NuGet` runs `PublishNuGetModule` and pushes to `NuGet:FeedUrl`. +- "The shared pipeline does not mark the GitHub release with the prerelease flag" — `Release:MarkPrerelease` defaults to `true`. +- "…and attaches the package artifacts" — assets are attached only when the caller passes `upload-artifacts: true`, which no consumer currently does. \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index d040e99..29c0d79 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ true 5.9.0 - 1.67.0 + 1.72.16 10.0.12 10.10.0 3.2.8 @@ -20,7 +20,7 @@ - + diff --git a/Justfile b/Justfile index b7d10e3..aae2c90 100644 --- a/Justfile +++ b/Justfile @@ -16,6 +16,13 @@ default_test_filter := "/*/*/*/*/" pipeline_feed := "https://api.nuget.org/v3/index.json" pipeline_tool := ".tools/purview-build/purview-build" +# The locally built binary the release/config scenario matrices run, so a sweep does not pay +# `dotnet run` startup per case and needs no network. +local_tool := "src/src/Build/bin/Release/net10.0/Purview.Build" +golden_test_project := tests_root / "Build.UnitTests" / "Build.UnitTests.csproj" +dogfood_artifacts := "./artifacts/dogfood" +dogfood_tool_path := "./.tools/dogfood" + current_version := `bun -p "require('./package.json').version"` [private] @@ -75,6 +82,57 @@ pipeline-pack-validate *args: echo "Running pack + validate pipeline..." "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} +# Pack this repository's tool from source, install it to a temp tool path, and run it against this repository +[group('Pipeline')] +pipeline-dogfood *args: + echo "Packing {{ BLUE }}{{ project }}{{ NORMAL }} from source..." + rm -rf "{{ dogfood_artifacts }}" "{{ dogfood_tool_path }}" + dotnet pack {{ project }} -c Release -o "{{ dogfood_artifacts }}" \ + -p:Version={{ current_version }} -p:PackageVersion={{ current_version }} + dotnet tool install Purview.Build --tool-path "{{ dogfood_tool_path }}" \ + --add-source "{{ dogfood_artifacts }}" --version "{{ current_version }}" + echo "Running the freshly packed tool against this repository..." + "{{ dogfood_tool_path }}/purview-build" {{ args }} + +# Explain the release decision for the working tree, without running any module or mutating anything +[group('Release')] +release-explain *args: + just scenario-build + "{{ local_tool }}" release-explain {{ args }} + +# Explain the release decision for a simulated ref and version; mutates nothing and makes no network calls +# Example: just release-simulate refs/heads/release/2.0 2.0.2 --Release:Eligibility:Policy=TrunkReservesMinor +[group('Release')] +release-simulate ref version *args: + just scenario-build + sh src/tests/scenarios/simulate.sh "{{ ref }}" "{{ version }}" {{ args }} + +# Run every eligibility scenario through the real CLI; non-zero on any mismatch +[group('Release')] +release-matrix: + just scenario-build + sh src/tests/scenarios/run-eligibility-matrix.sh + +# Run every config-resolution scenario through the real CLI; non-zero on any mismatch +[group('Release')] +config-matrix: + just scenario-build + sh src/tests/scenarios/run-config-matrix.sh + +# Regenerate the release-explain JSON golden file, then show what changed +[group('Release')] +release-explain-golden: + PURVIEW_BUILD_UPDATE_GOLDEN=1 dotnet test {{ golden_test_project }} -c Release \ + --treenode-filter "/*/*/ReleaseExplainGoldenTests/*" + git --no-pager diff -- src/tests/fixtures/release-explain.golden.json + +# Build the tool in Release so the scenario matrices can run the real binary +[private] +scenario-build: + if [ ! -x "{{ local_tool }}" ] && [ ! -x "{{ local_tool }}.exe" ]; then \ + dotnet build {{ project }} -c Release; \ + fi + # Build the project with the specified configuration, defaulting to "Debug" [group('Build and Test')] build *args: @@ -95,6 +153,13 @@ test filter=default_test_filter *args: test-unit *args: just test "/*/*/*/*[Category=Unit]" {{ args }} +# Run the eligibility and config-resolution suites only +[group('Build and Test')] +test-eligibility *args: + echo "Running eligibility and config-resolution suites..." + dotnet test {{ golden_test_project }} -c {{ build_configuration }} \ + --treenode-filter "/*/*/EligibilityScenarioTests|EligibilityPolicyResolutionTests|ConfigResolutionScenarioTests|ReleaseOnMainCharacterisationTests|ReleaseExplainGoldenTests|WorkflowParityTests|ReleaseUnitTests|ReleasePublicationTests/*" {{ args }} + # Clean the project with the specified configuration, defaulting to "Debug" [group('Build and Test')] clean *args: @@ -130,10 +195,24 @@ version: vs: open {{ project }} -# Check code formatting using CSharpier +# Check code formatting using CSharpier, and lint the workflow files [group('Utilities')] lint-check: dotnet csharpier check . + just lint-yaml + +# Lint the GitHub Actions workflow files with actionlint, when it is installed +# (action.yml is a composite action, not a workflow, so actionlint cannot parse it) +# Install it with 'scoop install actionlint', 'brew install actionlint', or +# 'go install github.com/rhysd/actionlint/cmd/actionlint@latest'. +[group('Utilities')] +lint-yaml: + if command -v actionlint >/dev/null 2>&1; then \ + echo "Running actionlint..."; \ + actionlint .github/workflows/*.yml; \ + else \ + echo "actionlint is not installed; skipping workflow linting (see the recipe comment)."; \ + fi # Fix code formatting issues using CSharpier [group('Utilities')] diff --git a/README.md b/README.md index 7357eb9..941d48d 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ output and progress). ## Configuration -Add `purview-build.json` at the repository root. Everything is optional; defaults are baked into the tool. Configuration precedence is command line, environment variables, `purview-build.json`, then defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. +Add `purview-build.json`. Everything is optional; defaults are baked into the tool. Configuration precedence is command line, environment variables, `purview-build.json`, opt-in machine-local user config, then defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. + +The file is found at the repository root, or at `.config/`, `.build/`, `build/`, `.purview/` or `.github/` beneath it — first match wins, and any lower-priority file that also exists is reported as shadowed rather than merged. Select one explicitly with `--config ` or `PURVIEW_BUILD_CONFIG`. Relative paths inside the file always anchor to the repository root, wherever the file itself lives. Run `purview-build --help` to print the probe order and the path that resolved. See the [configuration reference](docs/wiki/Configuration-Reference.md#where-the-configuration-file-lives). The pipeline is dotnet-first but supports **Web** projects (Bun/JS/TS, e.g. the Astro/Starlight `purview-dev` portal) by setting `Build:ProjectType=Web`: restore/build/lint/test then run the repository's root `package.json` scripts (`bun install`, `bun run build`, `bun run format:check`/`bun run lint`, `bun run test`), and the pack step zips `Build:WebBuildOutput` (default `src/dist`) into `Build:ArtifactsFolder` for the GitHub release. Every Web command is overridable via the `Web*` settings below. @@ -139,7 +141,9 @@ The pipeline is dotnet-first but supports **Web** projects (Bun/JS/TS, e.g. the } ``` -Secrets must not be committed. They are supplied through `NUGET_APIKEY` (or `NuGet__ApiKey`), `GITHUB_TOKEN`, and `LOCAL_NUGET_FEED_PATH` (or `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH`). +Secrets must not be committed. They are supplied through `NUGET_APIKEY` (or `NuGet__APIKey`), `GITHUB_TOKEN`, and `LOCAL_NUGET_FEED_PATH` (or `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH`). + +Release eligibility is rule-based and selected per repository with `Release:Eligibility:Policy`: `ReleaseOnMain` (the default, reproducing the pipeline's long-standing behaviour), `TrunkReservesMinor` (per-line release branches), or `FourPartServicing`. Run `purview-build release-explain` to see the decision, the rule trail and the publication that would result, without running anything. See [release models](docs/wiki/Release-Models.md). See the [Documentation](#documentation) section below for the architecture, configuration reference, and release strategy. @@ -147,15 +151,18 @@ See the [Documentation](#documentation) section below for the architecture, conf ```text CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → Validate → Publish → GitHub release + └→ ReportEligibility ``` -`CleanArtifacts` deletes and recreates `Build:ArtifactsFolder` before anything else runs, so pack, validation, publishing, and release uploads only ever see the packages from the current run (set `Build:CleanArtifacts=false` to keep existing artifacts). `Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow). +`Lint` and `ReportEligibility` are gates and reports, not inputs: nothing depends on either. + +`CleanArtifacts` deletes and recreates `Build:ArtifactsFolder` before anything else runs, so pack, validation, publishing, and release uploads only ever see the packages from the current run (set `Build:CleanArtifacts=false` to keep existing artifacts). `Version` resolves the release units from `Version:Source` (default: the `version` field of the root `package.json`); `Version:Strictness` defaults to `NuGet`, which accepts four-part versions. `ReportEligibility` evaluates the release rules and reports the verdict without acting on it. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) — a preset over the independent `Release:Publish` and `Release:GitHubRelease` switches — and by the `Build__Run*` switches. `Release:DryRun` runs everything and skips both publish steps. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow). ## Repository CI/CD This repository dogfoods the shared tool: CI builds and packs the tool from source, installs the generated package, then runs `purview-build` against this repository so the project builds and packs itself. Restore and warnings-as-errors compilation gate every pull request and merge. -On a push to `main`, the release workflow rebuilds and reinstalls the tool from the current source, then runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` pointing at nuget.org, and `Release__UploadArtifacts=true`. The tool therefore publishes the immutable package to `https://api.nuget.org/v3/index.json` and tags and releases itself (`v{Version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. +On a push to `main`, the release workflow rebuilds and reinstalls the tool from the current source, asks it to evaluate release eligibility (`release-explain --format=json`), and — when the verdict is `Release` — runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` pointing at nuget.org, and `Release__UploadArtifacts=true`. The tool therefore publishes the immutable package to `https://api.nuget.org/v3/index.json` and tags and releases itself (`v{Version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. GitHub initially creates NuGet packages as private. To make sure every package is **Internal** (consumable by all Purview-Dev members), an organization owner should set the org default: Purview-Dev → Settings → Packages → **Package Creation** → **Internal**, and change any already-published package's visibility in its **Package settings** → **Danger Zone**. See [docs/wiki/Release-Flow.md](docs/wiki/Release-Flow.md) for the exact steps and the `gh api` alternative. @@ -166,3 +173,4 @@ GitHub initially creates NuGet packages as private. To make sure every package i - [Architecture](docs/wiki/Architecture.md) - [Configuration reference](docs/wiki/Configuration-Reference.md) - [Release flow](docs/wiki/Release-Flow.md) +- [Release models](docs/wiki/Release-Models.md) diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 915d1cb..01c393b 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -17,28 +17,57 @@ An MSBuild SDK remains a possible future companion for shared compile-time prope The package owns module implementation, dependency ordering, safe defaults, secret lookup, NuGet/GitHub integration, and diagnostics. Each repository owns its tool-version pin, paths and discovery patterns, feature switches, and release-mode selection. A project needing truly custom behavior can invoke its own command before/after the shared tool; a generally useful variation should be added as a typed option here. +## The five layers + +A release passes through five layers. Which side of the ownership boundary each sits on is the thing to keep straight: + +| Layer | Owned by | What it decides | +| --- | --- | --- | +| **Trigger** | the **workflow** | Whether a release attempt happens at all: the caller's `on:` block and its concurrency group | +| **Version** | the **tool** | What is being released: `Version:Source` resolves an ordered set of release units (version, line, channel, tag) | +| **Eligibility** | **evaluated by the tool, decided by the workflow** | Whether this version may be released from this ref: the `REL0nn` rules produce a verdict and a message; the workflow reads it and sets `Release__Mode` | +| **Execution** | the **tool** | Clean, restore, build, test, lint, pack, validate | +| **Publication** | the **tool** | Push packages to the channel's feed, create `v{version}` and the GitHub release | + +The eligibility layer is the one worth being precise about. Before this split, the decision lived entirely in the reusable workflow as bash — unreachable from a unit test and unrunnable locally without `act`. Moving the *evaluation* into the tool (`release-explain`, plus `ReportEligibilityModule` for observability during a real run) makes it testable and locally runnable while leaving the *decision* with the workflow. The tool never silently declines to release when it has been asked to; it reports, and the workflow acts. + +`release-explain` is an informational command in the same family as `-v` and `--help`: it prints and exits without running any module, so it adds no node to the execution graph. `ReportEligibilityModule` is category `Build`, not `Release`, and every failure path inside it is a warning — a misconfigured policy must not fail a build that would otherwise succeed. + +## Configuration discovery + +`purview-build.json` is resolved before the pipeline is built: an explicit `--config` / `PURVIEW_BUILD_CONFIG` location, or the first hit from a documented probe list held as data. Relative paths inside the file always anchor to the repository root, never to the file's own directory, so moving the file changes nothing else. See [Configuration Reference](Configuration-Reference.md#where-the-configuration-file-lives). + +Because configuration is composed before any module (and therefore any pipeline context) exists, the locality check that gates opt-in machine-local user configuration is a first-party helper rather than `ctx.IsRunningLocally()`. + ## Module ordering The pipeline is registered in `BuildPipeline.cs` (`Program.cs` is the CLI boundary: informational options, the version banner, configuration binding, and failure reporting) in this order, with explicit `[DependsOn]` edges defining the graph: ```text -CleanArtifactsModule → RestoreModule → BuildModule → RunTestsModule ─┐ -CleanArtifactsModule → RestoreModule → LintModule ├→ PackModule → ValidatePackModule -CleanArtifactsModule → VersionModule ────────────────────────────────┘ +CleanArtifactsModule → RestoreModule → BuildModule → RunTestsModule ──┬→ PackModule → ValidatePackModule +CleanArtifactsModule → RestoreModule → LintModule │ +CleanArtifactsModule → VersionModule ─────────────────────────────────┤ + VersionModule → ReportEligibilityModule │ + │ + (LintModule and ReportEligibilityModule + are not inputs to PackModule) ``` Explicit `[DependsOn]` edges: - `VersionModule` and `RestoreModule` depend on `CleanArtifactsModule`, so the artifacts folder is reset before any other module starts. - `BuildModule` depends on `RestoreModule`. -- `LintModule` depends on `RestoreModule` (Web lint needs the dependencies installed by `bun install`; dotnet lint is unaffected beyond running after restore). +- `LintModule` depends on `RestoreModule` (Web lint needs the dependencies installed by `bun install`; dotnet lint is unaffected beyond running after restore). **Nothing depends on `LintModule`** — it is a gate, not an input. - `RunTestsModule` depends on `BuildModule`. +- `ReportEligibilityModule` depends on `VersionModule`. Nothing depends on it; it only reports. - `PackModule` depends on `RunTestsModule` and `VersionModule`. - `ValidatePackModule` depends on `PackModule`. - `PublishNuGetModule` depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. - `PublishLocalNuGetModule` depends on `PackModule` and `ValidatePackModule`. - `CreateGitHubReleaseModule` depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. +Module categories are `Build` and `Release`. `PublishNuGetModule` and `CreateGitHubReleaseModule` are `Release`; everything else — including `PublishLocalNuGetModule`, which never reaches a remote feed, and `ReportEligibilityModule` — is `Build`. + See [Pipeline Modules](Pipeline-Modules.md) for per-module behavior and skip conditions. ## Repository root resolution @@ -61,15 +90,22 @@ dotnet purview-build --Build:TestPatterns=*IntegrationTests.csproj --Build:RunPa ## Release behavior +`Release:Mode` is a preset over two independent switches, `Release:Publish` and `Release:GitHubRelease`, either of which may be set explicitly to override what the preset implies: + - `None`: build/test/pack may run, but nothing publishes. - `LocalNuGet`: pushes packages to the resolved local feed for developer testing. Only honoured when the tool runs **locally**; it is ignored in CI, so it cannot be driven through the reusable workflows. -- `NuGet`: pushes packages to the configured feed and, by default, creates a GitHub release. +- `NuGet`: pushes packages to the channel's feed and, by default, creates a GitHub release. - `GitHubRelease`: creates a GitHub release (optionally uploading `ArtifactsFolder` assets) without publishing NuGet packages. -The workflow decides whether a version is eligible to release (for example, only an untagged version on `main` or `release`) and sets `Release__Mode`. Credentials remain CI secrets. +`Release:Channel` selects the feed and label policy, so a `preview` channel can publish to GitHub Packages with `GitHubRelease: false` — preview packages without a GitHub release. `Release:DryRun` runs everything and skips both publish steps, logging what each would have done. + +The workflow decides whether a version is eligible to release and sets `Release__Mode`; the tool evaluates and reports that eligibility (see [the five layers](#the-five-layers)). Credentials remain CI secrets. + +Publication precedes tagging, and publication is idempotent (`--skip-duplicate`), so re-running after a failed release is safe. ## See also - [Configuration Reference](Configuration-Reference.md) - [Pipeline Modules](Pipeline-Modules.md) -- [Release Flow](Release-Flow.md) \ No newline at end of file +- [Release Flow](Release-Flow.md) +- [Release Models](Release-Models.md) \ No newline at end of file diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 2b04e7e..7452fb6 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -1,10 +1,76 @@ # Configuration Reference -Configuration is optional in a consuming repository; defaults are baked into the tool. Add `purview-build.json` at the repository root to override them. +Configuration is optional in a consuming repository; defaults are baked into the tool. Add `purview-build.json` and override what you need. + +## Where the configuration file lives + +### Explicit location + +The file cannot select itself, so an explicit location is command line or environment only. + +| Form | Notes | +| --- | --- | +| `--config ` / `-c ` | Absolute, or relative to the current working directory | +| `PURVIEW_BUILD_CONFIG=` | Same resolution; consistent with `PURVIEW_BUILD_STACKTRACE` naming | + +`--config` beats `PURVIEW_BUILD_CONFIG`. When either is set, **probing is skipped entirely**. A path naming a directory resolves to `purview-build.json` inside it. An explicit path that does not exist **fails with exit code 1** — never a silent fallback to probing or to defaults, because an explicit location that quietly does nothing is worse than a failure. + +### Default probe order + +Relative to the resolved repository root. First match wins; probing stops there. + +| Order | Path | Rationale | +| --- | --- | --- | +| 1 | `purview-build.json` | The original location, so no existing repository moves | +| 2 | `.config/purview-build.json` | Beside `.config/dotnet-tools.json` and `.config/lefthook.yml` | +| 3 | `.build/purview-build.json` | Build-tooling folder | +| 4 | `build/purview-build.json` | Historic home of the vendored `build/PipelineCLI` | +| 5 | `.purview/purview-build.json` | Tool-namespaced, beside `.agents/` | +| 6 | `.github/purview-build.json` | Keeps CI configuration with the workflows that drive it | + +The list is data, not a hard-coded chain: adding a location is a single entry, and `--help` prints the order with no further change. No configuration file at all remains entirely valid — every key is optional, and absence does not warn. + +### Shadowing + +When more than one probe path exists, the first is used and a **warning** names every shadowed file with its full path. The run does not fail, and files are **never merged**: silent first-match-wins is how someone spends an afternoon editing a file the tool never reads, and merging would create invisible layering that the precedence chain below already handles more predictably. + +When nothing is found but a probe directory contains a filename within a small edit distance of `purview-build.json`, a single warning names it. It is not loaded. + +### Path anchoring + +`Build:Solution`, `Build:TestRoot`, `Build:ArtifactsFolder`, `Build:WebBuildOutput` and the `PackValidation` globs are relative to the **repository root**, wherever the configuration file was found. Anchoring to the configuration file's own directory would silently change the meaning of every existing path the moment a repository moved its config into `.config/`. + +### `MODULAR_PIPELINES_DIRECTORY` is unrelated + +`MODULAR_PIPELINES_DIRECTORY` controls **only** the directory containing the tool's shipped `appsettings.json`. It has nothing to do with locating `purview-build.json`. The two are orthogonal, and confusing them is easy: one points at the tool's own defaults, the other at a repository's overrides. + +### User-level configuration — opt-in, local-only + +A `$HOME`-level file is a convenience for machine-specific values, chiefly `PublishLocalNuGet:LocalFeedPath`. Locations, in order: + +1. `$XDG_CONFIG_HOME/purview-build/purview-build.json` +2. `~/.config/purview-build/purview-build.json` +3. `%APPDATA%\purview-build\purview-build.json` (Windows) + +It is also a reproducibility hazard — a machine-local file silently altering a build is the same class of problem as an empty forwarded environment variable — so: + +- **Off by default.** Enable with `--user-config` or `PURVIEW_BUILD_USER_CONFIG=1`. +- **Ignored when not running locally**, exactly as `Release:Mode=LocalNuGet` is. It cannot influence a CI build. +- **Lower precedence than the repository configuration**, higher than `appsettings.json`. +- When active, the resolved path is logged at `Information`. ## Precedence -Command line > environment variables > `purview-build.json` > baked-in defaults (`appsettings.json`) > code-level defaults. +```text +--config / PURVIEW_BUILD_CONFIG selects WHICH file + +command line + > environment variables (__ nesting) + > + > user config, when opted in and running locally + > appsettings.json (shipped) + > C# property initialisers +``` - Environment variables use `__` for nesting, for example `Release__Mode=NuGet`. - Command-line overrides use configuration syntax, for example `--Build:RunPack=false`. @@ -15,11 +81,14 @@ Command line > environment variables > `purview-build.json` > baked-in defaults | Option | Behaviour | | --- | --- | | `-v`, `--version` | Print the tool version and exit without running the pipeline. | -| `-h`, `--help`, `-?` | Print usage, options, and configuration keys, then exit. | +| `-h`, `--help`, `-?` | Print usage, options, the probe order, and the resolved configuration path (or "none found"), then exit. | +| `release-explain` | Print the release decision and exit without running any module. `--format=json` emits the stable contract the reusable workflow consumes. See [Local Development](Local-Development.md). | Failures are reported the way a CLI build tool reports them: the tool prints the failing module and that module's output, then exits with code 1. Set `PURVIEW_BUILD_STACKTRACE=1` to add stack traces when diagnosing the tool itself. +Malformed JSON in a configuration file fails with the file path and, where the parser supplies them, the line and position — never a bare deserialisation exception. + ## `Build` | Key | Default | Purpose | @@ -57,11 +126,13 @@ output, then exits with code 1. Set `PURVIEW_BUILD_STACKTRACE=1` to add stack tr | `RequiredCompilerFlags` | `[]` | Compiler-flag `key=value` entries that must appear in each assembly's PDB compiler-flags record (e.g. `optimization=release`) | | `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package). Entries may use the `$(TFM)` target-framework partial token, e.g. `lib/$(TFM)/Foo.dll` | | `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package). Also supports the `$(TFM)` partial token | -| `RequireExplicitContent` | `false` | Makes `RequiredContent` exhaustive: every generated package must match a rule, and every non-metadata entry must match a declared glob; undeclared packages/entries are errors | +| `RequireExplicitContent` | `true` | Makes `RequiredContent` exhaustive: every generated package must match a rule, and every non-metadata entry must match a declared glob; undeclared packages/entries are errors | Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Entries may also contain the `$(TFM)` partial token (e.g. `lib/$(TFM)/Foo.dll`), which expands to one entry per target framework the package actually ships (short folder name, discovered from the package's own content groups); each expanded entry is checked independently. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. When `RequireExplicitContent` is `true`, `RequiredContent` becomes exhaustive: every generated package must match a rule, and every non-metadata entry in a matched package must match a declared (and `$(TFM)`-expanded) glob — undeclared packages or entries are errors. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). > **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above. +> +> `RequireExplicitContent` is the opposite case and was previously documented incorrectly as `false`. It was **absent** from `appsettings.json`, so the C# initialiser's `true` survived and the effective default has always been `true`. It is now stated explicitly in `appsettings.json` so the shipped value is visible rather than inferred. Behaviour is unchanged. Note what `true` means with an empty `RequiredContent`: every produced package must declare its content, so a repository that packs anything without a matching `RequiredContent` rule gets an error. Set `"RequireExplicitContent": false` to opt out, as this repository's own `purview-build.json` does. ## `NuGet` @@ -69,7 +140,7 @@ Content entry paths and package-id keys are matched as globs (case-insensitive), | --- | --- | --- | | `FeedUrl` | nuget.org v3 | Remote package source | | `TrustedPublishing` | `false` | Push without an API key (NuGet Trusted Publishing / OIDC) | -| `APIKey` | unset | Secret; use `NUGET_APIKEY` or `NuGet__ApiKey` | +| `APIKey` | unset | Secret; use `NUGET_APIKEY` or `NuGet__APIKey` (configuration keys are case-insensitive, so `NUGET__APIKEY` also binds here) | | `EnvAPIKey` | unset | Binds `NuGet__NUGET_APIKEY`; also falls back to process env `NUGET_APIKEY`/`NUGET_API_KEY` | ## `PublishLocalNuGet` @@ -90,14 +161,126 @@ Content entry paths and package-id keys are matched as globs (case-insensitive), | `EnvAccessToken` | unset | Binds `GitHub__GITHUB_TOKEN`; also falls back to process env `GITHUB_TOKEN` | | `ProductHeader` | `Purview.Build.Pipeline` | GitHub API product header | +## `Version` + +| Key | Default | Purpose | +| --- | --- | --- | +| `Source` | `PackageJson` | Where the version comes from. `PackageJson` reads the `version` field of the repository root `package.json` — the only implemented source | +| `Strictness` | `NuGet` | `NuGet` accepts everything `NuGetVersion` does, including four-part ("legacy") versions such as `13.5.3.10` and one/two-part versions such as `1.0`. `SemVer2` is the stricter opt-in: three numeric components with optional prerelease and build metadata | + +> **Why `NuGet` is the default.** It is exactly what the pipeline has always accepted. Two consuming repositories ship four-part versions (`aspirec4` at `13.5.3.10`, `build-sdk` at `1.0.2.2`), so defaulting to `SemVer2` would stop them releasing. Set `Version:Strictness=SemVer2` per repository to tighten it. + +`MinVer`, `release-please` and changesets cannot produce a four-part version, so that path is manual-only — an escape hatch, not a routine flow. `REL006` gates where such a version may be released from. + ## `Release` | Key | Default | Purpose | | --- | --- | --- | -| `Mode` | `None` | `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease` | +| `Mode` | `None` | Preset: `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease`. Derives `Publish` and `GitHubRelease` | +| `Publish` | derived from `Mode` | Push packages. Explicitly setting it wins over the `Mode` preset | +| `GitHubRelease` | derived from `Mode`, then the channel | Create the tag and GitHub release. Explicitly setting it wins over both | +| `Channel` | `stable` | Named channel, selecting the feed and the label policy | +| `Channels` | `{}` | Channel name → `{ FeedUrl, GitHubRelease, MarkPrerelease, LabelPattern }` | +| `DryRun` | `false` | Run the full pipeline but skip publishing and the GitHub release, logging what each would have done | | `UploadArtifacts` | `false` | Upload every file in `Build:ArtifactsFolder` as GitHub release assets | | `MarkPrerelease` | `true` | Create the GitHub release as a prerelease when the package version is a prerelease (for example `2.0.0-prerelease.25`). Affects GitHub release metadata only — NuGet publication is unaffected | +`Mode` remains the primary switch, and the derived values reproduce the pipeline's long-standing behaviour exactly: + +| `Mode` | `Publish` | `GitHubRelease` | +| --- | --- | --- | +| `None` | `false` | `false` | +| `NuGet` | `true` | `true` | +| `GitHubRelease` | `false` | `true` | +| `LocalNuGet` | `false` | `false` | + +### `Release:Channels` + +A channel lets a preview line publish elsewhere without cutting a GitHub release: + +```json +{ + "Release": { + "Mode": "NuGet", + "Channel": "preview", + "Channels": { + "preview": { + "FeedUrl": "https://nuget.pkg.github.com/purview-dev/index.json", + "GitHubRelease": false, + "MarkPrerelease": true, + "LabelPattern": "preview" + } + } + } +} +``` + +An unset channel member falls back to the top-level `Release`/`NuGet` setting. An explicitly set `Release:Publish`/`Release:GitHubRelease` wins over the channel. + +### `Release:Eligibility` + +Rules carry permanent `REL0nn` identifiers: never renumbered, never repurposed. Evaluation short-circuits in ID order, and **skip** (already released; exit 0) is distinct from **fail** (policy violation; exit 1). + +| ID | Rule | Enabled by default | +| --- | --- | --- | +| `REL001` | Version parses as valid for the configured strictness | yes | +| `REL002` | `v{version}` is not already tagged, and the version is not already on the target feed | yes | +| `REL003` | A stable (non-prerelease) version originates only from a ref in `StableRefs` | yes | +| `REL004` | A non-zero PATCH component originates only from a ref matching `ServicingRefs` | no | +| `REL005` | Version is strictly greater than the highest existing version on the same line | no | +| `REL006` | A four-part version is permitted only when `AllowFourPart` and the ref matches `FourPartRefs` | no | +| `REL007` | The prerelease label matches the channel's allowed pattern | no | + +`REL002` checks the tag always. The feed half is only judged when `Release:Context:PublishedVersions` supplies the feed state; otherwise the rule reports that the feed was not consulted. That matches the gate this replaces, which only ever checked the tag, and keeps publication idempotent through `--skip-duplicate`. + +Three policies ship built in, so adopting a different release model is one setting plus the caller's `on:` block — no policy block to copy: + +| Policy | Rules | Refs | +| --- | --- | --- | +| `ReleaseOnMain` (default) | `REL001`, `REL002`, `REL003` | `StableRefs`: `refs/heads/main`, `refs/heads/release` | +| `TrunkReservesMinor` | `REL001`–`REL005` | `StableRefs`/`ServicingRefs`: `refs/heads/release/*`; `TrunkRefs`: `refs/heads/main` | +| `FourPartServicing` | `TrunkReservesMinor` + `REL006` | as above, plus `AllowFourPart` and `FourPartRefs`: `refs/heads/release/*` | + +| Key | Default | Purpose | +| --- | --- | --- | +| `Policy` | `ReleaseOnMain` | The selected policy name | +| `Policies` | `{}` | Additional policies, merged over the built-ins. Declaring a built-in's name overrides it | + +Each policy accepts: + +| Key | Purpose | +| --- | --- | +| `Inherits` | Name of the policy this one is a diff from. A missing parent is an error naming the unresolved policy | +| `Rules` | Either an absolute list (`["REL001","REL002"]`) or deltas against the parent (`["+REL006","-REL005"]`). Mixing the two forms is an error | +| `StableRefs`, `ServicingRefs`, `TrunkRefs`, `FourPartRefs` | Ref patterns. A single trailing `*` is a prefix match (`refs/heads/release/*`). A non-empty child list replaces the parent's, so a policy can narrow a ref set | +| `AllowFourPart` | Whether four-part versions are permitted at all (`REL006`) | + +```json +{ + "Release": { + "Eligibility": { + "Policy": "TrunkReservesMinor" + } + } +} +``` + +> **The ownership boundary.** The tool *evaluates* eligibility and reports it; the workflow *decides* and sets `Release__Mode`. The tool never silently declines to release when it has been asked to. + +### `Release:Context` — simulated evaluation + +All optional, all honoured by `release-explain`. Used to answer "what would happen" on a developer machine. + +| Key | Purpose | +| --- | --- | +| `Ref` | Override the evaluated ref (defaults to `GITHUB_REF`, else the current git branch) | +| `ExistingTags` | Path to a newline- or JSON-delimited tag list, used instead of querying git | +| `PublishedVersions` | Path to a version list treated as already on the feed, instead of querying it | + +When any of these is set, evaluation performs **no process or network lookups** and every report states that simulated context was used — a simulated verdict must never be mistakable for a real one. The real repository's branch and tags do not leak in. + +**Interlock:** if any `Release:Context:*` key is set and the run would really publish, the pipeline fails before any module runs. Use `release-explain` or `Release:DryRun=true` to evaluate without publishing. + ## Example ```json diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index d20d5f8..7f32335 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -52,7 +52,28 @@ on: branches: [release] ``` -The reusable release workflow checks whether `v{version}` (read from `package.json`) is already tagged and skips if so, so merging `main` into `release` releases exactly once. +For the **per-line release-branch model** (Model C), where merging to `main` ships nothing and each +`release/` branch services its own line: + +```yaml +on: + push: + branches: ['release/**'] + workflow_dispatch: + +jobs: + release: + uses: purview-dev/build/.github/workflows/purview-release.yml@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + secrets: inherit +``` + +The reusable release workflow asks the tool to evaluate eligibility and reads the verdict: an +already-released version skips (the job finishes green), and a policy violation fails the job naming +the rule. Merging `main` into `release` therefore releases exactly once. See +[Release Models](Release-Models.md). The reusable workflows install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index dfc360e..5278583 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -20,6 +20,7 @@ The same implementation is available three ways: - [Pipeline Modules](Pipeline-Modules.md) - [Pack Validation](Pack-Validation.md) - [Release Flow](Release-Flow.md) +- [Release Models](Release-Models.md) - [Local Development](Local-Development.md) - [Secrets and Environment Variables](Secrets-and-Environment-Variables.md) - [Repository CI/CD](Repository-CI-CD.md) @@ -28,13 +29,15 @@ The same implementation is available three ways: ## Pipeline ```text -Version ───────────────┐ -Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release - └→ Lint │ -Version ───────────────┘ +CleanArtifacts ─┬→ Version ─┬──────────────────────────────┬→ Pack → Validate → Publish → GitHub release + │ └→ ReportEligibility │ + └→ Restore ─┬→ Build → Test ───────────────┘ + └→ Lint ``` -`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI. +`Version` resolves the release units from `Version:Source` (default: the `version` field of the root `package.json`). `ReportEligibility` evaluates the `Release:Eligibility` rules and reports the verdict without acting on it. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) — a preset over the independent `Release:Publish` and `Release:GitHubRelease` switches — and by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI. + +`Lint` and `ReportEligibility` are gates and reports: nothing depends on either. ## Requirements @@ -50,4 +53,8 @@ Version ───────────────┘ | `.github/actions/purview-build` | The shared composite action | | `.github/workflows` | The reusable `purview-build.yml`/`purview-release.yml` and this repository's own `ci.yml`/`release.yml` | | `docs/wiki` | This wiki | -| `purview-build.json` | This repository's own pipeline configuration (the tool dogfoods itself) | \ No newline at end of file +| `purview-build.json` | This repository's own pipeline configuration (the tool dogfoods itself) | +| `src/tests/Build.UnitTests` | Unit suites. The project name gives every test `[Category("Unit")]` automatically (the Purview SDK derives it from the `*.Tests` suffix) | +| `src/tests/Build.IntegrationTests` | Integration suites, likewise `[Category("Integration")]`. CI runs the unit filter only | +| `src/tests/fixtures` | The declarative scenario matrices and the `release-explain` golden file | +| `src/tests/scenarios` | The shell runners behind `just release-matrix` / `just config-matrix` | \ No newline at end of file diff --git a/docs/wiki/Local-Development.md b/docs/wiki/Local-Development.md index 290437a..44c9bb3 100644 --- a/docs/wiki/Local-Development.md +++ b/docs/wiki/Local-Development.md @@ -24,10 +24,112 @@ Omit `--version` to install the latest stable release. For a pinned local tool m | `just pipeline-release` | Run the shared tool with `Release:Mode=NuGet` | | `just pipeline-tests` | Run the shared tool with tests enabled | | `just pipeline-local-release` | Lint-fix, then run the shared tool with `Release:Mode=LocalNuGet` | +| `just pipeline-dogfood` | Pack the tool from source, install it to a temp tool path, and run it against this repository | +| `just test-eligibility` | The eligibility and config-resolution suites only | +| `just lint-yaml` | `actionlint` over the workflow files (skipped with a note when not installed) | | `just clean-all` / `just scrub` | Clean build outputs | `just pipeline-*` installs the `Purview.Build` tool to `.tools/purview-build` from nuget.org when it is not already present. +## Answering "did I break anything?" + +| Recipe | Purpose | +| --- | --- | +| `just release-explain *args` | Explain the release decision for the working tree | +| `just release-simulate ref version *args` | Explain the decision for a simulated ref and version | +| `just release-matrix` | Run every eligibility scenario through the real CLI; non-zero on any mismatch | +| `just config-matrix` | Run every config-resolution scenario through the real CLI | +| `just release-explain-golden` | Regenerate the `release-explain` JSON golden file and show the diff | + +`just release-matrix` and `just config-matrix` are the two commands to run before opening a pull +request that touches release behaviour. Both need **no secrets and no network**: every case supplies +its own ref and tag list through `Release:Context:*`, which suppresses all process and network +lookups. Both build the tool first (a restore is needed on a genuinely fresh clone) and then run the +real binary, so the shipped CLI and the in-process evaluator are held to one specification — the same +fixture files drive the TUnit suites: + +- `src/tests/fixtures/eligibility-scenarios.json` +- `src/tests/fixtures/config-resolution-scenarios.json` + +Adding a rule or a probe location means appending cases to those files, not writing test code. + +Each run creates throwaway repositories under `.scenario-runs/` (gitignored) and leaves the working +tree untouched. + +## `release-explain` + +Prints the release decision and exits **0** without running any module and without mutating +anything. The decision's own exit code is reported inside the output, for a caller to act on. + +```shell +just release-explain +just release-explain --config .config/purview-build.json +./.tools/purview-build/purview-build release-explain --format=json +``` + +It reports the resolved configuration path, the probe trail, any shadowed files, whether user +configuration was active, the resolved release units, the selected policy and its fully resolved rule +list, every rule in evaluation order with its verdict and message, the short-circuit point, the +`Release__Mode` / `Release:Publish` / `Release:GitHubRelease` / feed URL that would result, and the +ref it evaluated against with where that ref came from. + +`--format=json` is the contract the reusable release workflow consumes. It is pinned by a golden file +(`src/tests/fixtures/release-explain.golden.json`), because consuming repositories pin the workflow +by ref: a change in shape or in a rule message is a change to a published contract. + +### Simulating a ref and version + +```shell +just release-simulate refs/heads/main 1.1.0 +just release-simulate refs/heads/release/2.0 2.0.2 --Release:Eligibility:Policy=TrunkReservesMinor +just release-simulate refs/heads/release/2.1 2.1.0-prerelease.1 --Release:Eligibility:Policy=TrunkReservesMinor +``` + +The output is clearly marked `SIMULATED`, and the real repository's branch and tags do not leak in — +a simulated verdict must never be mistakable for a real one. + +## Safety interlocks + +A local run cannot perform a real publish or create a tag: + +- `release-explain` never mutates anything. +- If any `Release:Context:*` key is set and the run would really publish, the pipeline **fails before + any module runs**, naming the conflict. Simulated context must never drive a real publish. +- `Release:DryRun=true` runs the full pipeline but skips publishing and the GitHub release, logging + what each would have done. +- `Release:Mode=LocalNuGet` is honoured only when running locally and is ignored in CI. + +## End-to-end local rehearsal + +`Release:Mode=LocalNuGet` is the natural rehearsal path. Install the tool to a separate tool path +first — running the Release-configuration build against the binary that is executing it will fail on +a locked file: + +```shell +dotnet pack src/Build.slnx -c Release -o ./artifacts/dogfood \ + -p:Version=$(bun -p "require('./package.json').version") \ + -p:PackageVersion=$(bun -p "require('./package.json').version") +dotnet tool install Purview.Build --tool-path ./.tools/dogfood \ + --add-source ./artifacts/dogfood --version $(bun -p "require('./package.json').version") + +LOCAL_NUGET_FEED_PATH=/tmp/purview-local-feed ./.tools/dogfood/purview-build --Release:Mode=LocalNuGet +``` + +`just pipeline-dogfood` does the pack-and-install part for you. + +The rehearsal exercises Clean → Version → ReportEligibility → Restore → Build → Test → Lint → Pack → +ValidatePack → local publish, and provably does **not** create a tag, create a GitHub release, or +touch a remote feed: `PublishNuGetModule` and `CreateGitHubReleaseModule` both report `Skipped`, even +when `NUGET_APIKEY` and `GITHUB_TOKEN` are present in the environment. + +For a dry run against the real publication path instead: + +```shell +./.tools/dogfood/purview-build --Release:Mode=NuGet --Release:DryRun=true +``` + +Both publish steps then log `Would have …` and perform no action. + ## Local NuGet publishing `Release:Mode=LocalNuGet` pushes packages to a local feed and is only honoured when the tool runs **locally** — it is ignored in CI. @@ -46,7 +148,21 @@ By default the module overwrites existing packages, clears the NuGet global-pack ## Repository root resolution -The tool locates the repository root by walking up from the current working directory to the nearest `package.json`. Run `purview-build` from within the repository. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json`. +The tool locates the repository root by walking up from the current working directory to the nearest `package.json`. Run `purview-build` from within the repository. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing the tool's shipped `appsettings.json` — it has nothing to do with locating `purview-build.json`, which has its own [probe order](Configuration-Reference.md#default-probe-order). + +## Windows paths and `just` + +`just` runs recipes through a shell that strips backslashes from unquoted arguments, so a +backslash-based Windows path is mangled before the tool sees it. Use forward slashes, or the +`LOCAL_NUGET_FEED_PATH` environment variable: + +```shell +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +``` + +The local feed path must be absolute. A drive-relative path such as `p:foo` — the classic signature +of stripped backslashes — is rejected with a remediation message rather than silently resolving +against the current directory. ## See also diff --git a/docs/wiki/Migration-aspire-resourcekit.md b/docs/wiki/Migration-aspire-resourcekit.md index 9996b4d..3402c74 100644 --- a/docs/wiki/Migration-aspire-resourcekit.md +++ b/docs/wiki/Migration-aspire-resourcekit.md @@ -21,4 +21,4 @@ This repository currently contains a vendored copy of `build/PipelineCLI`. Migra 4. In the release caller set `release-mode: NuGet` and `secrets: inherit` (`NUGET_APIKEY` and `GITHUB_TOKEN` are read by the shared workflow). 5. Run the PR pipeline, then delete `build/PipelineCLI` and its pipeline-only central package declarations (`ModularPipelines*`, `NuGet.Packaging/Versioning`). -The old solution path and unit-test filter are preserved exactly. Other repositories migrate by changing only the JSON paths/patterns; for example `dotnet-project-sdk` can list unit and integration project globs in `Build:TestPatterns`. \ No newline at end of file +The old solution path and unit-test filter are preserved exactly. Other repositories migrate by changing only the JSON paths/patterns; for example `build-sdk` can list unit and integration project globs in `Build:TestPatterns`. \ No newline at end of file diff --git a/docs/wiki/Pipeline-Modules.md b/docs/wiki/Pipeline-Modules.md index df3aeb5..10d3164 100644 --- a/docs/wiki/Pipeline-Modules.md +++ b/docs/wiki/Pipeline-Modules.md @@ -6,8 +6,12 @@ Each module logs a line when it starts (`Running BuildModule...`) before its com ```text CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → ValidatePack → Publish → GitHub release + └→ ReportEligibility ``` +`Lint` and `ReportEligibility` are gates and reports, not inputs: nothing depends on either, so +neither feeds `Pack`. + ## CleanArtifactsModule Deletes `Build:ArtifactsFolder` (when it exists) and recreates it empty, before any other module runs. The folder is shared output: pack writes it, validation inspects every package in it, publishing moves packages out of it, and the release step can upload its contents — so a leftover package from an earlier (or differently configured) run would otherwise be validated, published, or uploaded as if it belonged to this run. @@ -16,7 +20,23 @@ Deletes `Build:ArtifactsFolder` (when it exists) and recreates it empty, before ## VersionModule -Reads the SemVer `version` field from the repository root `package.json` and produces a `NuGetVersion`. Fails when the file is missing, the field is missing/empty, or the value is not valid SemVer. The version feeds `PackModule` (via `Version`/`PackageVersion`) and `CreateGitHubReleaseModule` (via the `v{version}` tag). +Resolves the release units **once**, through the provider selected by `Version:Source` (`PackageJson` is the default and the only implemented source), and exposes them as an immutable module result. Every downstream module reads that result; none recomputes a version. + +The result is an ordered **set** of release units, not a scalar, even though every current source yields exactly one. Each unit carries its id, version, prerelease flag and label, line (`MAJOR.MINOR`), channel, tag (`v{version}`) and source. Modelling it as a set means adding a second unit requires no change in `PackModule` or `CreateGitHubReleaseModule`, both of which enumerate the set. + +`PackageJson` reads the `version` field from the repository root `package.json` and validates it against `Version:Strictness`. The default, `NuGet`, accepts everything `NuGetVersion` does — including four-part versions such as `13.5.3.10`, which two consuming repositories ship. `SemVer2` is the stricter opt-in. Fails when the file is missing, the field is missing/empty, or the value does not satisfy the configured strictness. + +The tag preserves the version exactly as written in `package.json`, rather than its normalised form: a normalised tag would drop a trailing `.0` revision and desynchronise the tag from the declared version. + +## ReportEligibilityModule + +Category `Build`. Depends on `VersionModule`. No skip condition. + +Evaluates the `Release:Eligibility` rules and **reports** the verdict — the policy name, every rule's outcome and message in evaluation order, and the short-circuit point — to the log and the run summary. It never acts on the verdict. + +Deliberately not category `Release`: it observes, and it must never decline a release. The workflow owns the decision (it runs `release-explain`, reads the verdict and sets `Release__Mode`); this module exists so a real pipeline run still records *why* a release was or was not eligible, which a workflow-only gate never surfaced in the tool's own log. + +Every failure path inside it is a warning. A misconfigured policy or an unreadable simulated tag list must not fail a build that would otherwise succeed. When `Release:Context:*` supplied a simulated context, the report says so. ## RestoreModule @@ -53,8 +73,10 @@ Depends on `RunTestsModule` and `VersionModule`. Skip condition: skipped when `B `CleanArtifactsModule` resets `Build:ArtifactsFolder` before the run produces anything, so the folder only contains packages from the current run. -- **DotNet**: creates `Build:ArtifactsFolder` and runs `dotnet pack` against `Build:Solution` with `Build:Configuration`, `--output `, and `-p:PackageVersion= -p:Version=` where the version comes from `VersionModule`. -- **Web**: creates `Build:ArtifactsFolder` and zips `Build:WebBuildOutput` (default `src/dist`) into `-.zip` (name from the root `package.json` `name` field, version from `VersionModule`). Logs a warning and produces no artifact when the build output directory does not exist. +Packs once per release unit, so a multi-unit version source needs no change here. + +- **DotNet**: creates `Build:ArtifactsFolder` and runs `dotnet pack` against `Build:Solution` with `Build:Configuration`, `--output `, and `-p:PackageVersion= -p:Version=` where the version comes from the release unit resolved by `VersionModule`. +- **Web**: creates `Build:ArtifactsFolder` and zips `Build:WebBuildOutput` (default `src/dist`) into `-.zip` (name from the root `package.json` `name` field, version from the release unit). Logs a warning and produces no artifact when the build output directory does not exist. ## ValidatePackModule @@ -64,13 +86,15 @@ Inspects every `.nupkg`/`.snupkg` in `Build:ArtifactsFolder`. Because `CleanArti ## PublishNuGetModule -Category `Release`. Depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** `Release:Mode` is not `NuGet` **or** (`NuGet:TrustedPublishing` is false and no API key resolves via `NuGet:GetNuGetAPIKey()`). +Category `Release`. Depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** the resolved `Release:Publish` is false **or** (`NuGet:TrustedPublishing` is false and no API key resolves via `NuGet:GetNuGetAPIKey()`). -Pushes every `*.nupkg` in `Build:ArtifactsFolder` to `NuGet:FeedUrl` with `--skip-duplicate`. When `NuGet:TrustedPublishing` is true, pushes without an API key (NuGet Trusted Publishing / OIDC federation). +`Release:Publish` is derived from `Release:Mode` unless set explicitly, so the default skip behaviour is unchanged: it publishes when `Release:Mode=NuGet`. + +Pushes every `*.nupkg` in `Build:ArtifactsFolder` with `--skip-duplicate`, to the channel's `FeedUrl` when `Release:Channel` declares one and `NuGet:FeedUrl` otherwise. When `NuGet:TrustedPublishing` is true, pushes without an API key (NuGet Trusted Publishing / OIDC federation). When `Release:DryRun` is true, logs the push it would have made and performs none. ## PublishLocalNuGetModule -Depends on `PackModule` and `ValidatePackModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** the tool is not running **locally** (`ctx.IsRunningLocally()`) **or** `Release:Mode` is not `LocalNuGet`. This mode is intentionally ignored in CI. +Category `Build` — it never reaches a remote feed. Depends on `PackModule` and `ValidatePackModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** the tool is not running **locally** (`ctx.IsRunningLocally()`) **or** `Release:Mode` is not `LocalNuGet`. This mode is intentionally ignored in CI. Validates `PublishLocalNuGet:LocalFeedPath` (resolved via `GetLocalFeedPath()`, falling back to `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` and then process env `LOCAL_NUGET_FEED_PATH`). The path must be absolute; drive-relative paths such as `p:foo` (backslashes stripped by a sh-style shell) are rejected with a remediation message. See [Local Development](Local-Development.md). @@ -78,9 +102,13 @@ Moves the `.nupkg`/`.snupkg` files from `Build:ArtifactsFolder` into the local f ## CreateGitHubReleaseModule -Category `Release`. Depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. Skip condition: skipped unless `Release:Mode` is `NuGet` or `GitHubRelease` **and** a GitHub token resolves via `GitHub:GetGitHubToken()`. +Category `Release`. Depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. Skip condition: skipped unless the resolved `Release:GitHubRelease` is true **and** a GitHub token resolves via `GitHub:GetGitHubToken()`. + +`Release:GitHubRelease` is derived from the channel and then from `Release:Mode` unless set explicitly, so the default skip behaviour is unchanged: it releases when `Release:Mode` is `NuGet` or `GitHubRelease`. + +Creates one GitHub release per release unit, with that unit's tag and `GenerateReleaseNotes = true`. Releases whose version is a prerelease (for example `2.0.0-prerelease.25`) are created as GitHub prereleases unless `Release:MarkPrerelease` — or the channel's `MarkPrerelease` — is false, so prerelease builds are not presented as the latest stable release. This changes GitHub release metadata only: prerelease versions are still published to the NuGet feed — `PublishNuGetModule` is unaffected. When `Release:UploadArtifacts` is true, uploads every file in `Build:ArtifactsFolder` as a release asset — for Web projects this is the `-.zip` produced by `PackModule`. When `Release:DryRun` is true, logs the release it would have created and creates none. -Creates a GitHub release with tag `v{version}` and `GenerateReleaseNotes = true`. Releases whose version is a prerelease (for example `2.0.0-prerelease.25`) are created as GitHub prereleases unless `Release:MarkPrerelease` is false, so prerelease builds are not presented as the latest stable release. This changes GitHub release metadata only: prerelease versions are still published to the NuGet feed — `PublishNuGetModule` is unaffected. When `Release:UploadArtifacts` is true, uploads every file in `Build:ArtifactsFolder` as a release asset — for Web projects this is the `-.zip` produced by `PackModule`. The tag must not already exist; callers gate release eligibility (the tool does not skip an existing tag itself). +The tag must not already exist. The workflow gates release eligibility — the tool evaluates and reports it (`release-explain`, `ReportEligibilityModule`) but does not skip an existing tag itself. ## See also diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index c2318b8..738b3e1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -12,12 +12,54 @@ The version is declared by the `version` field in the repository's root `package ## Branch models -Each repository is gated by a pull-request build. Two release trigger models are supported; the consuming repository's tiny caller workflow chooses: +Each repository is gated by a pull-request build. Three release trigger models are supported; the consuming repository's tiny caller workflow chooses. See [Release Models](Release-Models.md) for diagrams. -- **Release on `main`**: the release caller triggers on `push: branches: [main]`. -- **Main-as-head / release branch**: development merges to `main`, and merging `main` into a `release` branch performs the release. The release caller triggers on `push: branches: [release]`. +| Model | Trigger | Policy | Who uses it | +| --- | --- | --- | --- | +| **A** — release on `main` | `push: branches: [main]` | `ReleaseOnMain` (default) | Every repository today | +| **B** — main-as-head / release branch | `push: branches: [release]` | `ReleaseOnMain` (default) | Documented; no repository uses it yet | +| **C** — per-line release branches | `push: branches: ['release/**']` + `workflow_dispatch` | `TrunkReservesMinor` | New | -In both models the reusable `purview-release.yml` workflow reads `package.json`'s `version`, skips when the `v{version}` tag already exists, and otherwise runs the pipeline with `Release__Mode` set. Because publication is idempotent (`--skip-duplicate`) and the tag is created by the workflow, re-merging `main` into `release` after a failed release is safe. +- **Model A**: development merges to `main`, and each merge may release. +- **Model B**: development merges to `main`, and merging `main` into a `release` branch performs the release. +- **Model C**: merging to `main` ships **nothing**. Work accumulates across many branches, and a release is cut by merging into (or dispatching from) a `release/` branch. A serviced stable line (`2.0.1` → `2.0.2` from `release/2.0`) coexists with an in-flight prerelease line (`2.1.0-prerelease.N` from `release/2.1`); the two release concurrently without interfering, because the monotonic-version rule is scoped to the line. + +Adopting Model C is a change to the caller workflow's `on:` block plus one setting: + +```yaml +on: + push: + branches: ['release/**'] + workflow_dispatch: +``` + +```json +{ "Release": { "Eligibility": { "Policy": "TrunkReservesMinor" } } } +``` + +In every model the reusable `purview-release.yml` workflow asks the tool to evaluate eligibility (`purview-build release-explain --format=json`), reads the verdict, and sets `Release__Mode` accordingly: + +- `Release` → run the pipeline with the caller's mode. +- `Skip` → the version is already released; the job finishes green and publishes nothing. +- `Fail` → a policy violation; the job fails with the rule's message. + +Because publication is idempotent (`--skip-duplicate`) and publication precedes tagging, re-running after a failed release remains safe. + +### The minor-reservation rule + +Model C's `TrunkReservesMinor` policy enables `REL004`, which requires a non-zero PATCH component to originate from a ref matching `ServicingRefs`. The effect is that trunk only ever cuts `x.y.0`: once a line exists, `x.y.1` onwards is serviced from that line's own `release/x.y` branch. That is what makes a stable line serviceable while the next minor is still in prerelease. + +`REL005` then requires the version to advance past the highest existing version **on the same line**, so `release/2.1` shipping `2.1.0-prerelease.4` never blocks `release/2.0` shipping `2.0.3`. + +### Why hand-pushed tags stay forbidden + +The tool owns tagging. `CreateGitHubReleaseModule` creates `v{version}`, and the tag must not already exist — which is also how `REL002` decides that a version is already released. A hand-pushed tag therefore makes the next legitimate release of that version look already-done and silently skip, and it decouples the tag from the publication that should accompany it. Model C does not change this: its decision point is a dispatch or a merge into the release head, never a hand-pushed tag. + +Releasing is still: bump `version` in `package.json`, and merge. + +### Rule reference + +See [Configuration Reference](Configuration-Reference.md#releaseeligibility) for the full `REL0nn` table, the built-in policies, and the `Inherits` / `+REL0nn` / `-REL0nn` delta syntax. Rule IDs are permanent: never renumbered, never repurposed. A future release model should be expressible as a new named policy plus at most one new rule. The reusable workflow does **not** define a concurrency group. GitHub Actions cancels a run as a deadlock when a caller workflow and the reusable workflow it calls share the same concurrency group (the caller's `purview-release-main` collided with the reusable workflow's `purview-release-${{ inputs.release-branch }}` resolving to the same value, producing *"Canceling since a deadlock was detected for concurrency group"*). Callers must own release serialization by defining their own `concurrency` block: @@ -58,7 +100,7 @@ NuGet versions are immutable; `--skip-duplicate` makes recovery safe if publicat ## For local validation ```shell -dotnet pack src/src/Build/Build.csproj -c Release -o artifacts -p:Version=0.2.4 -p:PackageVersion=0.2.4 +dotnet pack src/src/Build/Build.csproj -c Release -o artifacts -p:Version=$(bun -p "require('./package.json').version") -p:PackageVersion=$(bun -p "require('./package.json').version") dotnet tool install Purview.Build --tool-path ./.tools --add-source ./artifacts ./.tools/purview-build ``` diff --git a/docs/wiki/Release-Models.md b/docs/wiki/Release-Models.md new file mode 100644 index 0000000..d6e8bfa --- /dev/null +++ b/docs/wiki/Release-Models.md @@ -0,0 +1,173 @@ +# Release Models + +Diagrams for the three supported release trigger models. See [Release Flow](Release-Flow.md) for the +prose and [Configuration Reference](Configuration-Reference.md#releaseeligibility) for the rules. + +> The diagrams below are **raw Mermaid source**, in fenced blocks marked `text`. The published site +> (`mkdocs.yml`) configures `pymdownx.superfences` without a Mermaid custom fence, so a `mermaid` +> block would render as unstyled text rather than a diagram. Paste a block into any Mermaid renderer +> to view it. If Mermaid support is added to `mkdocs.yml`, change these fences to `mermaid`. + +## Model A — release on `main` + +Every consuming repository uses this today. Each merge to `main` may release. + +```text +gitGraph + commit id: "1.0.0" + branch feature/add-thing + commit + checkout main + merge feature/add-thing tag: "v1.1.0" + branch feature/fix-thing + commit + checkout main + merge feature/fix-thing tag: "v1.1.1" +``` + +```text +flowchart LR + PR[Pull request] -->|purview-build.yml| Gate[Build, lint, test] + Gate --> Merge[Merge to main] + Merge -->|push: branches main| Explain[purview-build release-explain] + Explain -->|Release| Pipeline[Pack, validate, publish, tag] + Explain -->|Skip: already tagged| Green[Job succeeds, nothing published] + Explain -->|Fail: policy violation| Red[Job fails, names the REL0nn rule] +``` + +Policy: `ReleaseOnMain` (the default). `StableRefs` is `refs/heads/main` and `refs/heads/release`. + +## Model B — main-as-head / release branch + +Documented, and supported by the default policy. No repository uses it yet. + +```text +gitGraph + commit id: "1.0.0" + branch release + checkout main + commit + commit + checkout release + merge main tag: "v1.1.0" + checkout main + commit + checkout release + merge main tag: "v1.2.0" +``` + +Development merges to `main`; merging `main` into `release` performs the release. The caller +triggers on `push: branches: [release]`. Policy: `ReleaseOnMain`. + +## Model C — per-line release branches + +Merging to `main` ships nothing. Work accumulates, and a release is cut from a `release/` +branch. A serviced stable line and an in-flight prerelease line advance independently. + +```text +gitGraph + commit id: "2.0.0" + branch release/2.0 + checkout main + commit id: "work" + commit id: "more work" + checkout release/2.0 + merge main tag: "v2.0.1" + checkout main + branch release/2.1 + commit id: "2.1 prep" + checkout release/2.1 + commit tag: "v2.1.0-prerelease.1" + checkout release/2.0 + commit tag: "v2.0.2" + checkout release/2.1 + commit tag: "v2.1.0-prerelease.2" +``` + +```text +flowchart TB + Main[main: trunk, reserves the minor] -->|merge, or cherry-pick| R20[release/2.0: serviced stable line] + Main -->|branch when the minor opens| R21[release/2.1: in-flight prerelease line] + R20 -->|push or workflow_dispatch| E20[release-explain] + R21 -->|push or workflow_dispatch| E21[release-explain] + E20 --> P20[Publish 2.0.2, tag v2.0.2, stable GitHub release] + E21 --> P21[Publish 2.1.0-prerelease.2, tag it, GitHub prerelease] + Main -.->|stable version from trunk| X[REL003 fails: trunk is not a StableRef] + Main -.->|non-zero PATCH from trunk| Y[REL004 fails: patch is serviced from the line] +``` + +Policy: `TrunkReservesMinor` — `REL001`–`REL005`, with `StableRefs` and `ServicingRefs` both +`refs/heads/release/*` and `TrunkRefs` `refs/heads/main`. + +Caller workflow: + +```yaml +on: + push: + branches: ['release/**'] + workflow_dispatch: + +concurrency: + # Callers own release serialization; the reusable workflow defines no concurrency group. + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + uses: purview-dev/build/.github/workflows/purview-release.yml@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + secrets: inherit +``` + +## Rule evaluation + +Rules short-circuit in ID order. The first non-passing rule decides the verdict. + +```text +flowchart TB + Start[Version from the configured source] --> R1{REL001: parses for the strictness?} + R1 -->|no| F1[Fail, exit 1] + R1 -->|yes| R2{REL002: already tagged or published?} + R2 -->|yes| S[Skip, exit 0 — nothing to do] + R2 -->|no| R3{REL003: stable version from a StableRef?} + R3 -->|no| F3[Fail, exit 1] + R3 -->|yes| R4{REL004: non-zero PATCH from a ServicingRef?} + R4 -->|no| F4[Fail, exit 1] + R4 -->|yes| R5{REL005: greater than the line's highest?} + R5 -->|no| F5[Fail, exit 1] + R5 -->|yes| R6{REL006: four-part version permitted here?} + R6 -->|no| F6[Fail, exit 1] + R6 -->|yes| R7{REL007: label matches the channel pattern?} + R7 -->|no| F7[Fail, exit 1] + R7 -->|yes| Rel[Release, exit 0] +``` + +Only the rules a policy enables are evaluated; a disabled rule is skipped entirely rather than +passing vacuously. `ReleaseOnMain` evaluates `REL001`–`REL003` only, which is why Model A and +Model B repositories keep behaving exactly as they always have. + +## Where each layer sits + +```text +flowchart LR + subgraph Workflow[Workflow layer — owns the decision] + T[Trigger: on push / workflow_dispatch] + D[Read the verdict, set Release__Mode] + end + subgraph Tool[Tool layer — owns evaluation and execution] + V[Version: resolve the release units] + E[Eligibility: evaluate REL0nn, report] + X[Execution: clean, restore, build, test, lint, pack, validate] + P[Publication: push packages, tag, GitHub release] + end + T --> V --> E --> D --> X --> P +``` + +## See also + +- [Release Flow](Release-Flow.md) +- [Configuration Reference](Configuration-Reference.md) +- [Local Development](Local-Development.md) +- [Architecture](Architecture.md) diff --git a/docs/wiki/Repository-CI-CD.md b/docs/wiki/Repository-CI-CD.md index 12d87c4..69b1e98 100644 --- a/docs/wiki/Repository-CI-CD.md +++ b/docs/wiki/Repository-CI-CD.md @@ -14,16 +14,21 @@ Runs on pull requests and pushes to `main`: 6. Install the packed tool from the `artifacts` source into a temp tool path. 7. **Dogfood**: run the freshly installed `purview-build` against this repository (with `GITHUB_TOKEN`). The tool restores, builds, lints, runs tests, packs, and validates itself. +This repository's `purview-build.json` sets `Build:TestFilter` to `/*/*/*/*[Category=Unit]`, so the dogfood run executes the unit suites only. Categories come from the test project names — `Build.UnitTests` and `Build.IntegrationTests` — which the Purview SDK turns into assembly-level TUnit categories; no test writes a `[Category]` attribute by hand. Run the integration suites locally with `just test`. + ## Release (`release.yml`) Runs on push to `main` and is serialized by its own `concurrency` group (`purview-build-release`, `cancel-in-progress: false`): -1. Read and SemVer-validate the `package.json` version; skip the whole job when `v{version}` is already tagged (the tag check makes re-merges safe). +1. Read the `package.json` version. This step is irreducible: the tool cannot read its own version for `dotnet pack` before it has been packed. It makes **no** eligibility decision. 2. Restore and build `src/Build.slnx` with `--warnaserror`. 3. Pack the tool from source with `-p:ContinuousIntegrationBuild=true`. 4. Verify the `NUGET__APIKEY` secret is set. 5. Install the packed tool. -6. **Run the release pipeline** with `Release__Mode=NuGet`, `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, and `Build__ValidatePack=true`, passing `GITHUB_TOKEN` and `NUGET_APIKEY`. +6. **Evaluate eligibility** by running the freshly installed `purview-build release-explain --format=json` and reading the verdict. `Skip` finishes the job green, `Fail` fails it with the rule's message, and `Release` continues. +7. **Run the release pipeline** with `Release__Mode` taken from the evaluated decision, plus `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, and `Build__ValidatePack=true`, passing `GITHUB_TOKEN` and `NUGET_APIKEY`. + +This repository dogfoods the eligibility evaluation as well as the pipeline: the `git rev-parse` tag check that used to live in this workflow is gone, replaced by the tool's own rules. The tool therefore publishes the immutable package to nuget.org and tags and releases itself (`v{version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. diff --git a/docs/wiki/Secrets-and-Environment-Variables.md b/docs/wiki/Secrets-and-Environment-Variables.md index 0b54f69..f9d0227 100644 --- a/docs/wiki/Secrets-and-Environment-Variables.md +++ b/docs/wiki/Secrets-and-Environment-Variables.md @@ -11,21 +11,29 @@ Command line > environment variables > `purview-build.json` > baked-in defaults. | Secret | Where it is used | Environment-var bound alias | | --- | --- | --- | | `NUGET_APIKEY` | NuGet push | `NuGet__NUGET_APIKEY` (binds `EnvAPIKey`); also read directly from process env `NUGET_APIKEY`/`NUGET_API_KEY` | -| `NuGet__ApiKey` | NuGet push | `APIKey` | +| `NuGet__APIKey` | NuGet push | `APIKey`. Configuration keys are case-insensitive, so the historical `NUGET__APIKEY` secret name binds here too | | `GITHUB_TOKEN` | GitHub release creation | `GitHub__GITHUB_TOKEN` (binds `EnvAccessToken`); also read directly from process env `GITHUB_TOKEN` | | `LOCAL_NUGET_FEED_PATH` | Local NuGet publishing | `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` (binds `EnvLocalFeedPath`); also read directly from process env `LOCAL_NUGET_FEED_PATH` | The config binder does not map plain `NUGET_APIKEY`/`GITHUB_TOKEN`/`LOCAL_NUGET_FEED_PATH` process env vars under their settings sections, so the settings classes fall back to reading the process environment directly. -## Test filter forwarding +## Optional-input forwarding -The reusable workflows (`purview-build.yml`, `purview-release.yml`) forward the caller's `test-filter` and `test-projects` inputs as `Build__TestFilter`/`Build__TestProjects` **only when they are non-empty**. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently disable the filter — see commit `4d72bf7`. +The reusable workflows (`purview-build.yml`, `purview-release.yml`) forward every **optional** input only when it is non-empty: `test-filter`, `test-projects`, `config-path`, `eligibility-policy`, `release-channel`, and `version-source`. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently erase a configured value — see commit `4d72bf7`. -## Diagnostics +Inputs that declare a `default:` (for example `release-mode`, `run-tests`) are never empty and are mapped directly into `env:`. + +This is enforced by a test (`WorkflowParityTests`), which asserts that every `Section__Key` the workflows set resolves to a real settings property, and that no optional input is mapped straight into `env:` without an `if [ -n … ]` guard. + +## Configuration and diagnostics variables | Variable | Purpose | | --- | --- | +| `PURVIEW_BUILD_CONFIG` | Selects **which** `purview-build.json` to read (`--config` wins over it). An explicit path that does not exist is an error, never a silent fallback. | +| `PURVIEW_BUILD_USER_CONFIG` | Set to `1` (or `true`) to also read machine-local user configuration. Ignored when not running locally. | | `PURVIEW_BUILD_STACKTRACE` | Set to `1` (or `true`) to include stack traces in failure reports. Unset, a failing run prints only the failing module and that module's output, then exits with code 1. | +| `MODULAR_PIPELINES_DIRECTORY` | The directory containing the tool's shipped `appsettings.json`. Unrelated to `purview-build.json` discovery. | +| `GITHUB_REF` | Read by the eligibility rules as the evaluated ref, unless `Release:Context:Ref` overrides it. | Pipeline verbosity is configured with `Build__LogLevel` (default `Information`, which reports each module's command output and progress); set `Build__LogLevel=Warning` for quiet CI logs. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 0d38431..91127bf 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -5,6 +5,7 @@ - [Pipeline Modules](Pipeline-Modules.md) - [Pack Validation](Pack-Validation.md) - [Release Flow](Release-Flow.md) +- [Release Models](Release-Models.md) - [Local Development](Local-Development.md) - [Secrets and Environment Variables](Secrets-and-Environment-Variables.md) - [Repository CI/CD](Repository-CI-CD.md) diff --git a/package.json b/package.json index be9b434..f23e7d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build", - "version": "0.3.6", + "version": "0.4.0", "private": true, "homepage": "https://purview.dev/projects/build/", "bugs": { diff --git a/purview-build.json b/purview-build.json index 43f9597..f9914e5 100644 --- a/purview-build.json +++ b/purview-build.json @@ -2,7 +2,8 @@ "Build": { "Solution": "src/Build.slnx", "TestRoot": "src/tests", - "TestPatterns": "*Tests.csproj" + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { "RequireSymbolPackage": true, diff --git a/src/Build.slnx b/src/Build.slnx index 9bfbfe6..428b45a 100644 --- a/src/Build.slnx +++ b/src/Build.slnx @@ -15,5 +15,6 @@ + diff --git a/src/src/Build/Configuration/ConfigFileLocator.cs b/src/src/Build/Configuration/ConfigFileLocator.cs new file mode 100644 index 0000000..b2eb964 --- /dev/null +++ b/src/src/Build/Configuration/ConfigFileLocator.cs @@ -0,0 +1,222 @@ +namespace Purview.Build.Configuration; + +/// +/// Resolves which purview-build.json the run uses: an explicitly selected file, or the first +/// hit from the documented probe list. +/// +/// +/// Paths inside the file stay anchored to the repository root wherever the file is found. Anchoring +/// to the config file's own directory would silently change the meaning of every existing relative +/// path the moment a repository moved its config into .config/. +/// +static class ConfigFileLocator +{ + public const string EnvironmentVariableName = "PURVIEW_BUILD_CONFIG"; + + public const string UserConfigEnvironmentVariableName = "PURVIEW_BUILD_USER_CONFIG"; + + /// + /// Resolves configuration for a run. + /// + /// The repository root, which every probe path is relative to. + /// The --config value, if any. Beats the environment variable. + /// Whether --user-config was passed. + /// + /// An explicitly selected path does not exist. Never falls back to probing or to defaults: an + /// explicit location that silently does nothing is worse than a failure. + /// + public static ConfigResolution Resolve( + string repositoryRoot, + string? explicitPath = null, + bool userConfigRequested = false + ) + { + var (userConfigPath, userConfigActive, userConfigSkipReason) = ResolveUserConfig( + userConfigRequested + ); + + var environmentPath = Environment.GetEnvironmentVariable(EnvironmentVariableName); + + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return new ConfigResolution + { + Path = ResolveExplicit(explicitPath, "--config"), + Source = ConfigSource.CommandLine, + UserConfigPath = userConfigPath, + UserConfigActive = userConfigActive, + UserConfigSkipReason = userConfigSkipReason, + }; + } + + if (!string.IsNullOrWhiteSpace(environmentPath)) + { + return new ConfigResolution + { + Path = ResolveExplicit(environmentPath, EnvironmentVariableName), + Source = ConfigSource.EnvironmentVariable, + UserConfigPath = userConfigPath, + UserConfigActive = userConfigActive, + UserConfigSkipReason = userConfigSkipReason, + }; + } + + // No explicit path was supplied, so probe the repository for a config file. + return Probe(repositoryRoot, userConfigPath, userConfigActive, userConfigSkipReason); + } + + /// + /// Resolves an explicitly supplied path, which may name a directory. + /// + static string ResolveExplicit(string path, string origin) + { + var fullPath = Path.GetFullPath(path); + + if (Directory.Exists(fullPath)) + { + var inDirectory = Path.Combine(fullPath, ConfigProbePaths.FileName); + + return File.Exists(inDirectory) + ? inDirectory + : throw new InvalidOperationException( + $"{origin} names the directory '{fullPath}', which contains no " + + $"{ConfigProbePaths.FileName}." + ); + } + + return File.Exists(fullPath) + ? fullPath + : throw new InvalidOperationException( + $"{origin} was set to '{path}', which does not exist (resolved to '{fullPath}'). " + + "Correct the path, or remove the setting to use the default probe order." + ); + } + + static ConfigResolution Probe( + string repositoryRoot, + string? userConfigPath, + bool userConfigActive, + string? userConfigSkipReason + ) + { + List trail = []; + + foreach (var probe in ConfigProbePaths.All) + { + var fullPath = Path.GetFullPath(Path.Combine(repositoryRoot, probe.RelativePath)); + trail.Add(new(probe.RelativePath, fullPath, File.Exists(fullPath))); + } + + var winner = trail.FirstOrDefault(result => result.Exists); + + return new ConfigResolution + { + Path = winner?.FullPath, + Source = winner is null ? ConfigSource.None : ConfigSource.Probe, + ProbeTrail = trail, + ShadowedPaths = winner is null + ? [] + : [.. trail.Where(result => result.Exists && result != winner).Select(result => result.FullPath)], + NearMissPath = winner is null ? FindNearMiss(trail) : null, + UserConfigPath = userConfigPath, + UserConfigActive = userConfigActive, + UserConfigSkipReason = userConfigSkipReason, + }; + } + + static (string? Path, bool Active, string? SkipReason) ResolveUserConfig(bool requested) + { + var fromEnvironment = Environment.GetEnvironmentVariable(UserConfigEnvironmentVariableName); + var optedIn = + requested + || ( + !string.IsNullOrWhiteSpace(fromEnvironment) + && ( + fromEnvironment.Trim() == "1" + || string.Equals(fromEnvironment.Trim(), "true", StringComparison.OrdinalIgnoreCase) + ) + ); + + if (!optedIn) + return (null, false, null); + + var path = UserConfigLocator.Find(); + if (path is null) + { + return ( + null, + false, + "user configuration was requested but no file exists at any candidate location" + ); + } + + if (!ExecutionEnvironment.IsRunningLocally()) + { + return ( + path, + false, + "user configuration is ignored when not running locally " + + $"({ExecutionEnvironment.DetectedBuildAgentVariable()} is set)" + ); + } + + return (path, true, null); + } + + /// + /// Looks for a file in any probe directory whose name is within a small edit distance of + /// purview-build.json, so an obvious typo is reported rather than read as "no config". + /// + static string? FindNearMiss(IReadOnlyList trail) + { + const int maxDistance = 3; + + foreach (var probe in trail) + { + var directory = Path.GetDirectoryName(probe.FullPath); + if (directory is null || !Directory.Exists(directory)) + continue; + + foreach (var candidate in Directory.EnumerateFiles(directory, "*.json")) + { + var name = Path.GetFileName(candidate); + if (string.Equals(name, ConfigProbePaths.FileName, StringComparison.OrdinalIgnoreCase)) + continue; + + var distance = EditDistance(name, ConfigProbePaths.FileName); + if (distance is > 0 and <= maxDistance) + return Path.GetFullPath(candidate); + } + } + + return null; + } + + /// + /// Case-insensitive Levenshtein distance, two rows at a time. + /// + static int EditDistance(string left, string right) + { + var previous = new int[right.Length + 1]; + var current = new int[right.Length + 1]; + + for (var column = 0; column <= right.Length; column++) + previous[column] = column; + + for (var row = 1; row <= left.Length; row++) + { + current[0] = row; + + for (var column = 1; column <= right.Length; column++) + { + var same = char.ToUpperInvariant(left[row - 1]) == char.ToUpperInvariant(right[column - 1]); + var substitution = previous[column - 1] + (same ? 0 : 1); + current[column] = Math.Min(Math.Min(current[column - 1] + 1, previous[column] + 1), substitution); + } + + (previous, current) = (current, previous); + } + + return previous[right.Length]; + } +} diff --git a/src/src/Build/Configuration/ConfigProbePaths.cs b/src/src/Build/Configuration/ConfigProbePaths.cs new file mode 100644 index 0000000..6dfc270 --- /dev/null +++ b/src/src/Build/Configuration/ConfigProbePaths.cs @@ -0,0 +1,34 @@ +namespace Purview.Build.Configuration; + +/// +/// Where the tool looks for purview-build.json when no explicit location is given. +/// +/// +/// Held as data rather than a hard-coded chain, so adding a location is a single entry, the order is +/// printable by --help, and the probe trail can be reported verbatim. +/// +static class ConfigProbePaths +{ + public const string FileName = "purview-build.json"; + + /// + /// Probe paths relative to the repository root, in order. First match wins. + /// + public static IReadOnlyList All { get; } = + [ + new(FileName, "Repository root — the original location, so no existing repository moves."), + new( + $".config/{FileName}", + "Beside .config/dotnet-tools.json and .config/lefthook.yml." + ), + new($".build/{FileName}", "Build-tooling folder."), + new($"build/{FileName}", "Historic home of the vendored build/PipelineCLI."), + new($".purview/{FileName}", "Tool-namespaced, beside .agents/."), + new($".github/{FileName}", "Keeps CI configuration with the workflows that drive it."), + ]; +} + +/// +/// One probe location and why it is on the list. +/// +sealed record ConfigProbePath(string RelativePath, string Rationale); diff --git a/src/src/Build/Configuration/ConfigResolution.cs b/src/src/Build/Configuration/ConfigResolution.cs new file mode 100644 index 0000000..d1b397d --- /dev/null +++ b/src/src/Build/Configuration/ConfigResolution.cs @@ -0,0 +1,64 @@ +namespace Purview.Build.Configuration; + +/// +/// How the repository configuration file was chosen. +/// +enum ConfigSource +{ + /// No configuration file was found. Entirely valid: every key is optional. + None, + + /// --config / -c. + CommandLine, + + /// PURVIEW_BUILD_CONFIG. + EnvironmentVariable, + + /// Found by probing the documented locations. + Probe, +} + +/// +/// The outcome of locating configuration: which file won, how it was chosen, what was shadowed, and +/// whether machine-local user configuration took part. +/// +sealed record ConfigResolution +{ + /// Absolute path of the repository configuration file, or null when none was found. + public string? Path { get; init; } + + public ConfigSource Source { get; init; } = ConfigSource.None; + + /// + /// Every path considered, in order. Empty when an explicit location skipped probing. + /// + public IReadOnlyList ProbeTrail { get; init; } = []; + + /// + /// Existing files at lower-priority probe locations, which are NOT loaded. Reported as a + /// warning: silent first-match-wins is how someone edits a file the tool never reads. + /// + public IReadOnlyList ShadowedPaths { get; init; } = []; + + /// Resolved machine-local configuration file, when opted in, local, and present. + public string? UserConfigPath { get; init; } + + /// True when actually took part in the configuration chain. + public bool UserConfigActive { get; init; } + + /// Why user configuration was not applied, when it was asked for but skipped. + public string? UserConfigSkipReason { get; init; } + + /// + /// A file in a probe directory whose name is close to purview-build.json, when nothing was + /// found. Reported so an obvious typo is not mistaken for "no configuration". + /// + public string? NearMissPath { get; init; } + + public bool Found => Path is not null; +} + +/// +/// One probe location and whether a file existed there. +/// +sealed record ConfigProbeResult(string RelativePath, string FullPath, bool Exists); diff --git a/src/src/Build/Configuration/ConfigurationChain.cs b/src/src/Build/Configuration/ConfigurationChain.cs new file mode 100644 index 0000000..d63bc3a --- /dev/null +++ b/src/src/Build/Configuration/ConfigurationChain.cs @@ -0,0 +1,156 @@ +namespace Purview.Build.Configuration; + +/// +/// The single definition of the configuration chain, so the pipeline and release-explain can +/// never disagree about what a run is configured with. +/// +/// +/// Order, lowest priority first: +/// +/// the shipped appsettings.json (located by MODULAR_PIPELINES_DIRECTORY, which +/// controls only that file and nothing about repository configuration discovery), +/// machine-local user configuration, when opted in and running locally, +/// the resolved repository configuration file, wherever it was found, +/// environment variables (__ for nesting), +/// command line. +/// +/// +static class ConfigurationChain +{ + /// + /// Resolves everything a run needs before any configuration is read. + /// + public static PipelineStartup ResolveStartup(ToolCommandLine commandLine) + { + ArgumentNullException.ThrowIfNull(commandLine); + + var pipelineDirectory = PipelineProjectDirectory.Find(); + var repositoryRoot = PathHelpers.FindRepositoryRoot(Environment.CurrentDirectory); + + var resolution = ConfigFileLocator.Resolve( + repositoryRoot, + commandLine.ConfigPath, + commandLine.UserConfig + ); + + if (resolution.Path is not null) + JsonConfigFile.Validate(resolution.Path); + + if (resolution.UserConfigActive && resolution.UserConfigPath is not null) + JsonConfigFile.Validate(resolution.UserConfigPath); + + return new(pipelineDirectory, repositoryRoot, resolution); + } + + /// + /// Applies the chain to a configuration builder. + /// + public static void Apply( + IConfigurationBuilder configuration, + PipelineStartup startup, + string[] args + ) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(startup); + + configuration.AddJsonFile( + Path.Combine(startup.PipelineDirectory, "appsettings.json"), + optional: false + ); + + if (startup.Resolution.UserConfigActive && startup.Resolution.UserConfigPath is not null) + configuration.AddJsonFile(startup.Resolution.UserConfigPath, optional: true); + + if (startup.Resolution.Path is not null) + configuration.AddJsonFile(startup.Resolution.Path, optional: true); + + configuration.AddEnvironmentVariables().AddCommandLine(args); + } + + /// + /// Builds a standalone configuration root for release-explain, which never creates a + /// pipeline. + /// + public static IConfigurationRoot Build(PipelineStartup startup, string[] args) + { + ConfigurationBuilder configuration = new(); + Apply(configuration, startup, args); + + return configuration.Build(); + } + + /// + /// The lines a run reports about where its configuration came from. + /// + public static IReadOnlyList DescribeResolution(ConfigResolution resolution) + { + ArgumentNullException.ThrowIfNull(resolution); + + List lines = + [ + resolution.Found + ? $"Configuration: {resolution.Path} (selected by {Describe(resolution.Source)})" + : "Configuration: none found; using the tool's built-in defaults.", + ]; + + if (resolution.UserConfigActive && resolution.UserConfigPath is not null) + lines.Add($"User configuration: {resolution.UserConfigPath} (active)"); + else if (resolution.UserConfigSkipReason is not null) + lines.Add($"User configuration: not applied — {resolution.UserConfigSkipReason}."); + + return lines; + } + + /// + /// The warnings a run reports: shadowed files, and a near-miss filename when nothing was found. + /// + public static IReadOnlyList DescribeWarnings(ConfigResolution resolution) + { + ArgumentNullException.ThrowIfNull(resolution); + + List warnings = []; + + if (resolution.ShadowedPaths.Count > 0) + { + warnings.Add( + $"warning: {resolution.ShadowedPaths.Count} other {ConfigProbePaths.FileName} " + + $"file(s) exist and are NOT being read, because '{resolution.Path}' comes first in the " + + "probe order:" + ); + warnings.AddRange(resolution.ShadowedPaths.Select(path => $" {path}")); + warnings.Add( + " Configuration files are not merged. Delete or consolidate the shadowed file(s)." + ); + } + + if (resolution.NearMissPath is not null) + { + warnings.Add( + $"warning: no {ConfigProbePaths.FileName} was found, but '{resolution.NearMissPath}' " + + "has a very similar name. It was NOT loaded; rename it if it was meant to configure " + + "the pipeline." + ); + } + + return warnings; + } + + static string Describe(ConfigSource source) => + source switch + { + ConfigSource.CommandLine => "--config", + ConfigSource.EnvironmentVariable => ConfigFileLocator.EnvironmentVariableName, + ConfigSource.Probe => "the default probe order", + ConfigSource.None or _ => "nothing", + }; +} + +/// +/// The directories and configuration resolution a run starts from. +/// +sealed record PipelineStartup( + string PipelineDirectory, + string RepositoryRoot, + ConfigResolution Resolution +); diff --git a/src/src/Build/Configuration/JsonConfigFile.cs b/src/src/Build/Configuration/JsonConfigFile.cs new file mode 100644 index 0000000..9b9b5ea --- /dev/null +++ b/src/src/Build/Configuration/JsonConfigFile.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace Purview.Build.Configuration; + +/// +/// Parse-checks a configuration file before it reaches the configuration binder. +/// +/// +/// The JSON configuration provider reports a malformed file as a bare deserialisation failure with +/// no path, which is useless when six probe locations are possible. Parsing first means the error +/// names the file and, where the parser supplies them, the line and position. +/// +static class JsonConfigFile +{ + /// The file exists but is not valid JSON. + public static void Validate(string path) + { + if (!File.Exists(path)) + return; + + try + { + using var stream = File.OpenRead(path); + using var document = JsonDocument.Parse( + stream, + new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + } + ); + } + catch (JsonException exception) + { + var position = + exception.LineNumber is { } line && exception.BytePositionInLine is { } bytePosition + // JsonException counts lines from zero; editors count from one. + ? $" at line {line + 1}, position {bytePosition + 1}" + : string.Empty; + + throw new InvalidOperationException( + $"'{path}' is not valid JSON{position}: {exception.Message}", + exception + ); + } + } +} diff --git a/src/src/Build/Configuration/UserConfigLocator.cs b/src/src/Build/Configuration/UserConfigLocator.cs new file mode 100644 index 0000000..b13f182 --- /dev/null +++ b/src/src/Build/Configuration/UserConfigLocator.cs @@ -0,0 +1,43 @@ +namespace Purview.Build.Configuration; + +/// +/// Locates the opt-in, machine-local user configuration file. +/// +/// +/// Exists chiefly for PublishLocalNuGet:LocalFeedPath, which is inherently machine-specific. +/// It is also a reproducibility hazard — a machine-local file silently altering a build is the same +/// class of problem as an empty forwarded environment variable — so it is off by default and ignored +/// whenever the tool is not running locally, exactly as Release:Mode=LocalNuGet is. +/// +static class UserConfigLocator +{ + const string DirectoryName = "purview-build"; + + /// + /// The candidate paths, in order, for the current platform. + /// + public static IReadOnlyList Candidates() + { + List candidates = []; + + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (!string.IsNullOrWhiteSpace(xdg)) + candidates.Add(Path.Combine(xdg, DirectoryName, ConfigProbePaths.FileName)); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + candidates.Add(Path.Combine(home, ".config", DirectoryName, ConfigProbePaths.FileName)); + + var appData = Environment.GetEnvironmentVariable("APPDATA"); + if (!string.IsNullOrWhiteSpace(appData)) + candidates.Add(Path.Combine(appData, DirectoryName, ConfigProbePaths.FileName)); + + return candidates; + } + + /// + /// The first candidate that exists, or null. + /// + public static string? Find() => + Candidates().FirstOrDefault(File.Exists) is { } found ? Path.GetFullPath(found) : null; +} diff --git a/src/src/Build/Helpers/BuildPipeline.cs b/src/src/Build/Helpers/BuildPipeline.cs index e3deab9..eb9feaa 100644 --- a/src/src/Build/Helpers/BuildPipeline.cs +++ b/src/src/Build/Helpers/BuildPipeline.cs @@ -1,4 +1,5 @@ using ModularPipelines.Models; +using Purview.Build.Configuration; namespace Purview.Build.Helpers; @@ -15,23 +16,28 @@ static class BuildPipeline /// /// Runs the pipeline and returns the failed module results (empty when every module succeeded or skipped). /// - public static async Task> RunAsync(string[] args) + public static async Task> RunAsync( + ToolCommandLine commandLine, + PipelineStartup startup + ) { - var pipelineDirectory = PipelineProjectDirectory.Find(); - var repositoryRoot = PathHelpers.FindRepositoryRoot(Environment.CurrentDirectory); + ArgumentNullException.ThrowIfNull(commandLine); + ArgumentNullException.ThrowIfNull(startup); + var args = commandLine.PipelineArguments; var builder = Pipeline.CreateBuilder(args); - AddConfiguration(builder, args, pipelineDirectory, repositoryRoot); + ConfigurationChain.Apply(builder.Configuration, startup, args); ApplyLogLevel(builder); BindSettings(builder); + ValidateInterlocks(builder); AddGitHubClient(builder); AddModules(builder); builder.ConfigurePipelineOptions(options => options.ThrowOnPipelineFailure = false); // Modules resolve every configured path relative to the repository root. - Environment.CurrentDirectory = repositoryRoot; + Environment.CurrentDirectory = startup.RepositoryRoot; await using var pipeline = await builder.BuildAsync(); @@ -40,17 +46,14 @@ public static async Task> RunAsync(string[] args) return summary.GetFailedModuleResults(); } - static void AddConfiguration( - PipelineBuilder builder, - string[] args, - string pipelineDirectory, - string repositoryRoot - ) => + /// + /// Rejects configurations that could cause real damage, before any module runs. + /// + static void ValidateInterlocks(PipelineBuilder builder) => builder - .Configuration.AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false) - .AddJsonFile(Path.Combine(repositoryRoot, "purview-build.json"), optional: true) - .AddEnvironmentVariables() - .AddCommandLine(args); + .Configuration.GetSection(ReleaseSettings.SectionName) + .Get() + ?.ValidateSimulationInterlock(); /// /// Applies Build:LogLevel to the pipeline logger, defaulting to so @@ -81,6 +84,9 @@ static void BindSettings(PipelineBuilder builder) builder.Services.Configure( builder.Configuration.GetSection(ReleaseSettings.SectionName) ); + builder.Services.Configure( + builder.Configuration.GetSection(VersionSettings.SectionName) + ); } static void AddGitHubClient(PipelineBuilder builder) => @@ -98,6 +104,7 @@ static void AddModules(PipelineBuilder builder) => builder .AddModule() .AddModule() + .AddModule() .AddModule() .AddModule() .AddModule() diff --git a/src/src/Build/Helpers/CLIConsole.cs b/src/src/Build/Helpers/CLIConsole.cs index 1bed2bc..319dbcc 100644 --- a/src/src/Build/Helpers/CLIConsole.cs +++ b/src/src/Build/Helpers/CLIConsole.cs @@ -9,10 +9,37 @@ namespace Purview.Build.Helpers; /// The pipeline's analyzers forbid direct use (module and step output belongs to the /// pipeline logger), so CLI-boundary output - the version banner, --help, and failure reports - goes /// through the same console abstraction the pipeline itself renders with. +/// +/// Every write lifts the console profile width first. hard-wraps at that +/// width, which is 80 columns whenever stdout is redirected - exactly the case when a workflow pipes +/// output into jq or greps a failure message. Wrapping inserts newlines inside JSON string +/// values and splits diagnostics mid-sentence, so the tool formats its own lines and the console +/// must not reflow them. /// static class CLIConsole { - public static void WriteLine(string text) => AnsiConsole.WriteLine(text); + public static void WriteLine(string text) => WriteUnwrapped(text); public static void WriteLine() => AnsiConsole.WriteLine(); -} \ No newline at end of file + + /// + /// Writes machine-readable output verbatim. + /// + public static void WriteRaw(string text) => WriteUnwrapped(text); + + static void WriteUnwrapped(string text) + { + var profile = AnsiConsole.Console.Profile; + var previousWidth = profile.Width; + + try + { + profile.Width = int.MaxValue; + AnsiConsole.WriteLine(text); + } + finally + { + profile.Width = previousWidth; + } + } +} diff --git a/src/src/Build/Helpers/ExecutionEnvironment.cs b/src/src/Build/Helpers/ExecutionEnvironment.cs new file mode 100644 index 0000000..fb6a9f9 --- /dev/null +++ b/src/src/Build/Helpers/ExecutionEnvironment.cs @@ -0,0 +1,52 @@ +namespace Purview.Build.Helpers; + +/// +/// Whether the tool is running on a developer machine or on a build agent, decided before any +/// pipeline context exists. +/// +/// +/// Modules use ctx.IsRunningLocally(), but configuration is composed before a context exists +/// and ModularPipelines' concrete build-system detector is internal to that assembly, so the +/// locality check that gates machine-local configuration has to live here. The variables below are +/// the ones the known build systems set; an empty value counts as unset, because a workflow that +/// forwards an undefined variable sets it to the empty string. +/// +static class ExecutionEnvironment +{ + /// + /// The variables that mark a build agent. Exposed so tests emulating a local run neutralise + /// every one of them rather than only the variable they happen to know about. + /// + public static IReadOnlyList BuildAgentVariables { get; } = + [ + // Generic, set by most hosted CI including GitHub Actions. + "CI", + "GITHUB_ACTIONS", + "TF_BUILD", + "TEAMCITY_VERSION", + "JENKINS_URL", + "JENKINS_HOME", + "GITLAB_CI", + "BITBUCKET_BUILD_NUMBER", + "TRAVIS", + "APPVEYOR", + ]; + + /// + /// True when no known build-agent variable is set. + /// + public static bool IsRunningLocally() => !IsRunningOnBuildAgent(); + + public static bool IsRunningOnBuildAgent() => + BuildAgentVariables.Any(variable => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variable)) + ); + + /// + /// Names the detected build agent variable, for diagnostics. Null when running locally. + /// + public static string? DetectedBuildAgentVariable() => + BuildAgentVariables.FirstOrDefault(variable => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variable)) + ); +} diff --git a/src/src/Build/Helpers/InformationalFlags.cs b/src/src/Build/Helpers/InformationalFlags.cs index 62e6d01..3b6a28e 100644 --- a/src/src/Build/Helpers/InformationalFlags.cs +++ b/src/src/Build/Helpers/InformationalFlags.cs @@ -26,9 +26,10 @@ public static InformationalFlag Parse(IReadOnlyList args) if (Matches(args, VersionFlags)) return InformationalFlag.Version; + // The help flags are recognized last, so that a user can ask for help about the version flag itself. return Matches(args, HelpFlags) ? InformationalFlag.Help : InformationalFlag.None; } static bool Matches(IReadOnlyList args, string[] flags) => args.Any(arg => flags.Contains(arg, StringComparer.OrdinalIgnoreCase)); -} \ No newline at end of file +} diff --git a/src/src/Build/Helpers/PackageInspector.cs b/src/src/Build/Helpers/PackageInspector.cs index aa08e32..0e7178c 100644 --- a/src/src/Build/Helpers/PackageInspector.cs +++ b/src/src/Build/Helpers/PackageInspector.cs @@ -164,7 +164,7 @@ CancellationToken cancellationToken var nupkgFiles = (await nupkgReader.GetFilesAsync(cancellationToken)).ToHashSet(StringComparer.OrdinalIgnoreCase); var snupkgFiles = snupkgReader is null - ? new(StringComparer.OrdinalIgnoreCase) + ? [with(StringComparer.OrdinalIgnoreCase)] : (await snupkgReader.GetFilesAsync(cancellationToken)).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in nupkgFiles) diff --git a/src/src/Build/Helpers/ToolCommandLine.cs b/src/src/Build/Helpers/ToolCommandLine.cs new file mode 100644 index 0000000..d006879 --- /dev/null +++ b/src/src/Build/Helpers/ToolCommandLine.cs @@ -0,0 +1,147 @@ +namespace Purview.Build.Helpers; + +/// +/// What the tool was asked to do. +/// +enum ToolCommand +{ + /// Run the pipeline (the default). + RunPipeline, + + /// Print the version and exit. + Version, + + /// Print the help and exit. + Help, + + /// Explain the release decision and exit, without running any module. + ReleaseExplain, +} + +/// +/// Output shape for release-explain. +/// +enum OutputFormat +{ + /// Human-readable, through the tool's console. + Text, + + /// The stable JSON contract the reusable workflow consumes. + Json, +} + +/// +/// The tool's own command line, separated from the configuration overrides the pipeline binds. +/// +/// +/// The configuration command-line provider accepts only --key=value / --key value +/// forms, so a bare verb (release-explain) or a single-dash option (-c path) would +/// make it throw. The tool's own arguments are therefore removed here and only the remainder is +/// handed to the pipeline. +/// +sealed record ToolCommandLine( + ToolCommand Command, + string? ConfigPath, + bool UserConfig, + OutputFormat Format, + string[] PipelineArguments +) +{ + const string ReleaseExplainVerb = "release-explain"; + + static readonly string[] ConfigFlags = ["--config", "-c"]; + + public static ToolCommandLine Parse(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + // Informational flags win, and are recognised before anything else can fail to parse. + var informational = InformationalFlags.Parse(args); + var command = informational switch + { + InformationalFlag.Version => ToolCommand.Version, + InformationalFlag.Help => ToolCommand.Help, + InformationalFlag.None or _ => ToolCommand.RunPipeline, + }; + + string? configPath = null; + var userConfig = false; + var format = OutputFormat.Text; + List remaining = []; + + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + + if (string.Equals(arg, ReleaseExplainVerb, StringComparison.OrdinalIgnoreCase)) + { + if (command == ToolCommand.RunPipeline) + command = ToolCommand.ReleaseExplain; + + continue; + } + + if (TryReadValue(args, ref index, ConfigFlags, out var config)) + { + configPath = config; + continue; + } + + if (TryReadValue(args, ref index, ["--format"], out var formatValue)) + { + format = string.Equals(formatValue, "json", StringComparison.OrdinalIgnoreCase) + ? OutputFormat.Json + : OutputFormat.Text; + continue; + } + + if (string.Equals(arg, "--user-config", StringComparison.OrdinalIgnoreCase)) + { + userConfig = true; + continue; + } + + remaining.Add(arg); + } + + return new(command, configPath, userConfig, format, [.. remaining]); + } + + /// + /// Reads --flag=value or --flag value, advancing past the value in the latter case. + /// + static bool TryReadValue( + string[] args, + ref int index, + IReadOnlyList flags, + out string? value + ) + { + var arg = args[index]; + value = null; + + foreach (var flag in flags) + { + if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase)) + { + if (index + 1 >= args.Length) + throw new InvalidOperationException($"'{flag}' requires a value."); + + value = args[++index]; + return true; + } + + var inline = flag + "="; + if (arg.StartsWith(inline, StringComparison.OrdinalIgnoreCase)) + { + value = arg[inline.Length..]; + + return string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"'{flag}' requires a value.") + : true; + } + } + + return false; + } +} diff --git a/src/src/Build/Helpers/ToolInfo.cs b/src/src/Build/Helpers/ToolInfo.cs index eac1bdb..c28bd24 100644 --- a/src/src/Build/Helpers/ToolInfo.cs +++ b/src/src/Build/Helpers/ToolInfo.cs @@ -1,3 +1,4 @@ +using Purview.Build.Configuration; using System.Reflection; namespace Purview.Build.Helpers; @@ -16,34 +17,88 @@ static class ToolInfo public static string VersionLine => $"{Name} {Version}"; + /// + /// The --help text, without configuration resolution. Kept for callers that have not + /// resolved configuration yet; is what the CLI prints. + /// + public static string HelpText => BuildHelpText(resolution: null); + /// /// The --help text. Treated like any other CLI build tool: usage and options first, then the - /// configuration keys the tool accepts. + /// configuration keys the tool accepts, then where configuration was actually found. /// - public static string HelpText => + public static string BuildHelpText(ConfigResolution? resolution) => $$""" {{VersionLine}} - shared build, test, pack, and release pipeline. Usage: purview-build [configuration overrides] + purview-build release-explain [--format=json] + + Commands: + release-explain Explain the release decision and exit, without running any module. + --format=json emits the stable contract the reusable workflow consumes. Options: - -v, --version Print the {{Name}} version and exit. - -h, --help Print this help and exit. + -v, --version Print the {{Name}} version and exit. + -h, --help Print this help and exit. + -c, --config Use this configuration file. Absolute, or relative to the current + directory; a directory resolves to purview-build.json inside it. + Skips probing entirely. A path that does not exist is an error. + --user-config Also read machine-local user configuration (local runs only). - Configuration precedence: command line > environment variables > purview-build.json > defaults. + Configuration precedence: command line > environment variables > purview-build.json + > user config (opt-in, local only) > defaults. --Build:RunPack=false command-line override Build__RunPack=false environment variable (nested keys use "__") - { "Build": { "RunPack": false } } purview-build.json at the repository root + { "Build": { "RunPack": false } } purview-build.json - Modules run in dependency order: CleanArtifacts, Version, Restore, Build, Lint, RunTests, Pack, - ValidatePack, PublishNuGet, PublishLocalNuGet, CreateGitHubRelease. Each logs a line when it starts and - when it finishes; a failing run reports the failed module's error and exits with code 1. + PURVIEW_BUILD_CONFIG= selects WHICH configuration file (--config wins over it) + PURVIEW_BUILD_USER_CONFIG=1 same as --user-config + MODULAR_PIPELINES_DIRECTORY unrelated: the directory holding the shipped + appsettings.json, not repository configuration + + {{DescribeProbeOrder()}} + {{DescribeResolvedConfig(resolution)}} + Relative paths in the configuration file (Build:Solution, Build:TestRoot, Build:ArtifactsFolder, + Build:WebBuildOutput, PackValidation globs) always resolve against the repository root, wherever + the configuration file itself was found. + + Modules run in dependency order: CleanArtifacts, Version, ReportEligibility, Restore, Build, Lint, + RunTests, Pack, ValidatePack, PublishNuGet, PublishLocalNuGet, CreateGitHubRelease. Each logs a line + when it starts and when it finishes; a failing run reports the failed module's error and exits 1. Set PURVIEW_BUILD_STACKTRACE=1 to include stack traces when an unexpected failure is reported. Documentation: https://purview.dev/docs/build/ """; + static string DescribeProbeOrder() + { + var lines = ConfigProbePaths.All.Select( + (probe, index) => $" {index + 1}. {probe.RelativePath,-32} {probe.Rationale}" + ); + + return $""" + Default probe order, relative to the repository root (first match wins, and any lower-priority + file that also exists is reported as shadowed, never merged): + {string.Join(Environment.NewLine, lines)} + """; + } + + static string DescribeResolvedConfig(ConfigResolution? resolution) + { + if (resolution is null) + return string.Empty; + + // The resolution is known, so report it. The tool's own help text is not a substitute for the + return $""" + + Resolved configuration for this directory: + {string.Join(Environment.NewLine, ConfigurationChain.DescribeResolution(resolution).Select(line => " " + line))} + + """; + } + static string ResolveVersion() { var assembly = typeof(ToolInfo).Assembly; @@ -60,4 +115,4 @@ static string ResolveVersion() return assembly.GetName().Version?.ToString() ?? "unknown"; } -} \ No newline at end of file +} diff --git a/src/src/Build/Helpers/WebScripts.cs b/src/src/Build/Helpers/WebScripts.cs index fcd5a8d..31d5591 100644 --- a/src/src/Build/Helpers/WebScripts.cs +++ b/src/src/Build/Helpers/WebScripts.cs @@ -14,7 +14,7 @@ public static IReadOnlyDictionary ReadScripts(string repositoryR if (!document.RootElement.TryGetProperty("scripts", out var scripts)) return new Dictionary(); - Dictionary result = new(StringComparer.OrdinalIgnoreCase); + Dictionary result = [with(StringComparer.OrdinalIgnoreCase)]; foreach (var property in scripts.EnumerateObject()) result[property.Name] = property.Value.GetString() ?? string.Empty; @@ -52,4 +52,4 @@ public static string ReadPackageName(string repositoryRoot) ? remainder : null; } -} \ No newline at end of file +} diff --git a/src/src/Build/Modules/CreateGitHubReleaseModule.cs b/src/src/Build/Modules/CreateGitHubReleaseModule.cs index 18c1e53..0c287ac 100644 --- a/src/src/Build/Modules/CreateGitHubReleaseModule.cs +++ b/src/src/Build/Modules/CreateGitHubReleaseModule.cs @@ -4,6 +4,7 @@ using ModularPipelines.GitHub.Extensions; using ModularPipelines.Models; using ModularPipelines.Modules; +using Purview.Build.Release; using System.Diagnostics.CodeAnalysis; namespace Purview.Build.Modules; @@ -16,22 +17,25 @@ public sealed class CreateGitHubReleaseModule( IOptions releaseSettings, IOptions gitSettings, IOptions buildSettings -) : Module +// Octokit.Release is qualified throughout: Purview.Build.Release is a namespace in this +// assembly, so the unqualified name is ambiguous here. +) : Module { protected override ModuleConfiguration Configure() => ModuleConfiguration .Create() .WithSkipWhen(_ => - releaseSettings.Value.Mode is not (ReleaseMode.NuGet or ReleaseMode.GitHubRelease) + !releaseSettings.Value.ShouldCreateGitHubRelease() || string.IsNullOrWhiteSpace(gitSettings.Value.GetGitHubToken()) ? SkipDecision.Skip( - "GitHub release creation is disabled. Set Release__Mode=NuGet (or GitHubRelease) and GITHUB_TOKEN to create a GitHub release." + "GitHub release creation is disabled. Set Release__Mode=NuGet (or GitHubRelease), or " + + "Release__GitHubRelease=true, and GITHUB_TOKEN to create a GitHub release." ) : SkipDecision.DoNotSkip ) .Build(); - protected override async Task ExecuteAsync( + protected override async Task ExecuteAsync( [NotNull] IModuleContext context, CancellationToken cancellationToken ) @@ -39,13 +43,25 @@ CancellationToken cancellationToken ModuleProgress.Starting(context, nameof(CreateGitHubReleaseModule)); var versionResult = await context.GetModule(); - var version = + var units = versionResult.ValueOrDefault ?? throw new InvalidOperationException( "The version was not produced by the version module." ); - var tag = $"v{version}"; + if (releaseSettings.Value.DryRun) + { + foreach (var unit in units.Units) + { + context.Logger.LogInformation( + "Release:DryRun is set. Would have created GitHub release {Tag}{Prerelease}.", + unit.Tag, + releaseSettings.Value.ShouldMarkPrerelease(unit.Version) ? " as a prerelease" : string.Empty + ); + } + + return []; + } var repositoryIdString = context.GitHub().EnvironmentVariables.RepositoryId; if (!long.TryParse(repositoryIdString, out var repositoryId)) @@ -55,17 +71,33 @@ CancellationToken cancellationToken ); } + List releases = []; + + foreach (var unit in units.Units) + releases.Add(await CreateReleaseAsync(context, repositoryId, unit, cancellationToken)); + + return [.. releases]; + } + + async Task CreateReleaseAsync( + IModuleContext context, + long repositoryId, + ReleaseUnit unit, + CancellationToken cancellationToken + ) + { // Create a new release on GitHub with the specified tag and generate release notes. // Prerelease versions are published as prereleases so they are not presented as the // latest stable release. - var isPrerelease = releaseSettings.Value.ShouldMarkPrerelease(version); + var isPrerelease = releaseSettings.Value.ShouldMarkPrerelease(unit.Version); + var release = await context .GitHub() .Client.Repository.Release.Create( repositoryId, - new NewRelease(tag) + new NewRelease(unit.Tag) { - Name = tag, + Name = unit.Tag, GenerateReleaseNotes = true, Prerelease = isPrerelease, } @@ -73,41 +105,45 @@ CancellationToken cancellationToken context.Logger.LogInformation( "Created GitHub release {Tag}{Prerelease}.", - tag, + unit.Tag, isPrerelease ? " as a prerelease" : string.Empty ); if (releaseSettings.Value.UploadArtifacts) - { - var artifactsFolder = buildSettings.Value.ArtifactsFolder; - if (Directory.Exists(artifactsFolder)) - { - foreach ( - var file in Directory.EnumerateFiles( - artifactsFolder, - "*.*", - SearchOption.TopDirectoryOnly - ) - ) - { - await using var stream = File.OpenRead(file); - await context - .GitHub() - .Client.Repository.Release.UploadAsset(release, new ReleaseAssetUpload - { - FileName = Path.GetFileName(file), - ContentType = "application/octet-stream", - RawData = stream, - } -, cancellationToken); - context.Logger.LogInformation( - "Uploaded release asset {File}.", - Path.GetFileName(file) - ); - } - } - } + await UploadArtifactsAsync(context, release, cancellationToken); return release; } + + async Task UploadArtifactsAsync( + IModuleContext context, + Octokit.Release release, + CancellationToken cancellationToken + ) + { + var artifactsFolder = buildSettings.Value.ArtifactsFolder; + if (!Directory.Exists(artifactsFolder)) + return; + + foreach ( + var file in Directory.EnumerateFiles(artifactsFolder, "*.*", SearchOption.TopDirectoryOnly) + ) + { + await using var stream = File.OpenRead(file); + await context + .GitHub() + .Client.Repository.Release.UploadAsset( + release, + new ReleaseAssetUpload + { + FileName = Path.GetFileName(file), + ContentType = "application/octet-stream", + RawData = stream, + }, + cancellationToken + ); + + context.Logger.LogInformation("Uploaded release asset {File}.", Path.GetFileName(file)); + } + } } diff --git a/src/src/Build/Modules/PackModule.cs b/src/src/Build/Modules/PackModule.cs index 8d2948d..a08517a 100644 --- a/src/src/Build/Modules/PackModule.cs +++ b/src/src/Build/Modules/PackModule.cs @@ -5,6 +5,7 @@ using ModularPipelines.DotNet.Options; using ModularPipelines.Models; using ModularPipelines.Modules; +using Purview.Build.Release; using System.Diagnostics.CodeAnalysis; using System.IO.Compression; @@ -13,7 +14,7 @@ namespace Purview.Build.Modules; [ModuleCategory("Build")] [DependsOn] [DependsOn] -public sealed class PackModule(IOptions settings) : Module +public sealed class PackModule(IOptions settings) : Module { protected override ModuleConfiguration Configure() => ModuleConfiguration @@ -27,7 +28,7 @@ protected override ModuleConfiguration Configure() => ) .Build(); - protected override async Task ExecuteAsync( + protected override async Task ExecuteAsync( [NotNull] IModuleContext context, CancellationToken cancellationToken ) @@ -35,7 +36,7 @@ CancellationToken cancellationToken ModuleProgress.Starting(context, nameof(PackModule)); var versionResult = await context.GetModule(); - var nugetVersion = + var units = versionResult.ValueOrDefault ?? throw new InvalidOperationException( "The version was not produced by the version module." @@ -43,10 +44,32 @@ CancellationToken cancellationToken Directory.CreateDirectory(settings.Value.ArtifactsFolder); - if (settings.Value.ProjectType == ProjectType.Web) - return PackWebArtifact(context, nugetVersion.ToString()); + List results = []; + + // Every version source yields a single unit today; packing per unit means a future + // multi-unit source needs no change here. + foreach (var unit in units.Units) + { + if (settings.Value.ProjectType == ProjectType.Web) + { + PackWebArtifact(context, unit); + continue; + } + + results.Add(await PackDotNetAsync(context, unit, cancellationToken)); + } + + return [.. results]; + } + + async Task PackDotNetAsync( + IModuleContext context, + ReleaseUnit unit, + CancellationToken cancellationToken + ) + { + var version = unit.Version.ToString(); - var version = nugetVersion.ToString(); var result = await context .DotNet() .Pack( @@ -65,8 +88,9 @@ CancellationToken cancellationToken return result; } - CommandResult? PackWebArtifact(IModuleContext context, string version) + void PackWebArtifact(IModuleContext context, ReleaseUnit unit) { + var version = unit.Version.ToString(); var repositoryRoot = PathHelpers.FindRepositoryRoot(); var packageName = WebScripts.ReadPackageName(repositoryRoot); @@ -80,7 +104,7 @@ CancellationToken cancellationToken settings.Value.WebBuildOutput ); - return null; + return; } var zipPath = Path.Combine( @@ -99,7 +123,5 @@ CancellationToken cancellationToken Path.GetFileName(zipPath) ); context.Logger.LogInformation("Packed version {Version}.", version); - - return null; } } diff --git a/src/src/Build/Modules/PublishNuGetModule.cs b/src/src/Build/Modules/PublishNuGetModule.cs index cddf149..565e65b 100644 --- a/src/src/Build/Modules/PublishNuGetModule.cs +++ b/src/src/Build/Modules/PublishNuGetModule.cs @@ -23,13 +23,13 @@ protected override ModuleConfiguration Configure() => .Create() .WithSkipWhen(_ => buildSettings.Value.ProjectType == ProjectType.Web - || releaseSettings.Value.Mode != ReleaseMode.NuGet + || !releaseSettings.Value.ShouldPublish() || ( !nugetSettings.Value.TrustedPublishing && string.IsNullOrWhiteSpace(nugetSettings.Value.GetNuGetAPIKey()) ) ? SkipDecision.Skip( - "NuGet publishing is disabled. Set Release__Mode=NuGet and either NuGet__ApiKey (or NUGET_APIKEY) or NuGet__TrustedPublishing=true to publish packages." + "NuGet publishing is disabled. Set Release__Mode=NuGet (or Release__Publish=true) and either NuGet__ApiKey (or NUGET_APIKEY) or NuGet__TrustedPublishing=true to publish packages." ) : SkipDecision.DoNotSkip ) @@ -62,6 +62,24 @@ CancellationToken cancellationToken ); } + // The channel's feed wins over NuGet:FeedUrl, so a preview channel can target a different + // feed without the caller rewriting NuGet:FeedUrl for every run. + var feedUrl = releaseSettings.Value.ResolveChannel().FeedUrl ?? nugetSettings.Value.FeedUrl; + + if (releaseSettings.Value.DryRun) + { + foreach (var package in packages) + { + context.Logger.LogInformation( + "Release:DryRun is set. Would have pushed {Package} to {FeedUrl}.", + Path.GetFileName(package), + feedUrl + ); + } + + return []; + } + var tasks = packages.Select(package => context .DotNet() @@ -69,7 +87,7 @@ CancellationToken cancellationToken new() { Path = package, - Source = nugetSettings.Value.FeedUrl, + Source = feedUrl, ApiKey = nugetSettings.Value.TrustedPublishing ? null : nugetSettings.Value.GetNuGetAPIKey(), diff --git a/src/src/Build/Modules/ReportEligibilityModule.cs b/src/src/Build/Modules/ReportEligibilityModule.cs new file mode 100644 index 0000000..8ea8400 --- /dev/null +++ b/src/src/Build/Modules/ReportEligibilityModule.cs @@ -0,0 +1,107 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Modules; +using Purview.Build.Release; +using System.Diagnostics.CodeAnalysis; + +namespace Purview.Build.Modules; + +/// +/// Evaluates the release-eligibility rules and reports the verdict, without acting on it. +/// +/// +/// Deliberately category Build, not Release: it observes, and it never declines a +/// release. The workflow owns the decision — it runs release-explain, reads the verdict and +/// sets Release__Mode — and the tool must not silently refuse to release when it has been +/// asked to. This module exists so a real pipeline run still records WHY a release was or was not +/// eligible, which a workflow-only gate never surfaced in the tool's own log. +/// +/// Every failure path here is a warning. A rule evaluation that throws (a misconfigured policy, an +/// unreadable simulated tag list) must not fail a build that would otherwise succeed. +/// +[ModuleCategory("Build")] +[DependsOn] +public sealed class ReportEligibilityModule( + IOptions releaseSettings, + IOptions versionSettings +) : Module +{ + protected override async Task ExecuteAsync( + [NotNull] IModuleContext context, + CancellationToken cancellationToken + ) + { + ModuleProgress.Starting(context, nameof(ReportEligibilityModule)); + + var versionResult = await context.GetModule(); + var units = versionResult.ValueOrDefault; + + if (units is null) + { + context.Logger.LogWarning( + "Release eligibility was not evaluated: the version module produced no release units." + ); + + return null; + } + + try + { + return Report(context, units); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + context.Logger.LogWarning( + exception, + "Release eligibility could not be evaluated. This is reported, not enforced, so the run continues." + ); + + return null; + } + } + + EligibilityDecision Report(IModuleContext context, ReleaseUnitSet units) + { + var release = releaseSettings.Value; + + var releaseContext = ReleaseContextProvider.Resolve(release.Context, Environment.CurrentDirectory); + var policy = EligibilityPolicies.Resolve(release.Eligibility, release.Eligibility.Policy); + + var input = EligibilityInput.Create( + units.RawVersion, + versionSettings.Value.Strictness, + releaseContext, + release.Channel, + release.ResolveChannel(), + units.Source + ); + + var decision = EligibilityEvaluator.Evaluate(input, policy); + + context.Logger.LogInformation( + "Release eligibility ({Policy}): {Verdict} — {Message}", + policy.Name, + decision.Verdict, + decision.Message + ); + + foreach (var result in decision.Results) + context.Logger.LogInformation(" {Outcome,-4} {Message}", result.Outcome, result.Message); + + if (releaseContext.Simulated) + { + context.Logger.LogWarning( + "Release:Context:* supplied a simulated release context, so this eligibility report is " + + "hypothetical." + ); + } + + context.Summary.KeyValue("Release", "Eligibility policy", policy.Name); + context.Summary.KeyValue("Release", "Eligibility verdict", decision.Verdict.ToString()); + + if (decision.RuleId is not null) + context.Summary.KeyValue("Release", "Decided by", decision.RuleId); + + return decision; + } +} diff --git a/src/src/Build/Modules/ValidatePackModule.cs b/src/src/Build/Modules/ValidatePackModule.cs index a2d11ce..da5c399 100644 --- a/src/src/Build/Modules/ValidatePackModule.cs +++ b/src/src/Build/Modules/ValidatePackModule.cs @@ -56,8 +56,8 @@ CancellationToken cancellationToken throw new InvalidOperationException($"No .nupkg files found in {artifactsFolder}."); } - List results = new(nupkgFiles.Length + snupkgFiles.Length); - Dictionary packagePairs = new(StringComparer.OrdinalIgnoreCase); + List results = [with(nupkgFiles.Length + snupkgFiles.Length)]; + Dictionary packagePairs = [with(StringComparer.OrdinalIgnoreCase)]; foreach (var package in nupkgFiles) { diff --git a/src/src/Build/Modules/VersionModule.cs b/src/src/Build/Modules/VersionModule.cs index 4584fa0..1a898a3 100644 --- a/src/src/Build/Modules/VersionModule.cs +++ b/src/src/Build/Modules/VersionModule.cs @@ -1,44 +1,52 @@ using ModularPipelines.Attributes; using ModularPipelines.Context; using ModularPipelines.Modules; -using NuGet.Versioning; +using Purview.Build.Release; +using Purview.Build.Version; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; namespace Purview.Build.Modules; +/// +/// Resolves the release units once, so every downstream module reads the same version rather than +/// recomputing it. +/// [ModuleCategory("Build")] [DependsOn] -public sealed class VersionModule : Module +public sealed class VersionModule( + IOptions versionSettings, + IOptions releaseSettings +) : Module { - protected override async Task ExecuteAsync( + protected override async Task ExecuteAsync( [NotNull] IModuleContext context, CancellationToken cancellationToken ) { ModuleProgress.Starting(context, nameof(VersionModule)); - var packageJsonPath = Path.Combine(Environment.CurrentDirectory, "package.json"); - - if (!File.Exists(packageJsonPath)) - throw new FileNotFoundException($"Could not find package.json at {packageJsonPath}"); - - var packageJson = await File.ReadAllTextAsync(packageJsonPath, cancellationToken); - - using var document = JsonDocument.Parse(packageJson); - var version = document.RootElement.GetProperty("version").GetString(); - - if (string.IsNullOrWhiteSpace(version)) - throw new InvalidOperationException( - "The version field in package.json is missing or empty." - ); - - if (!NuGetVersion.TryParse(version, out var nugetVersion)) - throw new InvalidOperationException( - $"The version '{version}' in package.json is not a valid SemVer." + var provider = VersionProviders.For(versionSettings.Value.Source); + + var units = await provider.ResolveAsync( + new VersionProviderContext( + RepositoryRoot: Environment.CurrentDirectory, + ChannelName: releaseSettings.Value.Channel, + Strictness: versionSettings.Value.Strictness + ), + cancellationToken + ); + + context.Summary.KeyValue("Version", "Package version", units.RawVersion); + + if (units.Units.Count > 1) + { + context.Summary.KeyValue( + "Version", + "Release units", + string.Join(", ", units.Units.Select(unit => unit.Id)) ); + } - context.Summary.KeyValue("Version", "Package version", version); - return nugetVersion; + return units; } } diff --git a/src/src/Build/Program.cs b/src/src/Build/Program.cs index 8d90c25..3c8e3d5 100644 --- a/src/src/Build/Program.cs +++ b/src/src/Build/Program.cs @@ -1,24 +1,61 @@ -var informationalFlag = InformationalFlags.Parse(args); +using Purview.Build.Configuration; +using Purview.Build.Release; -if (informationalFlag == InformationalFlag.Version) +var commandLine = ToolCommandLine.Parse(args); + +if (commandLine.Command == ToolCommand.Version) { CLIConsole.WriteLine(ToolInfo.VersionLine); return 0; } -if (informationalFlag == InformationalFlag.Help) +try { - CLIConsole.WriteLine(ToolInfo.HelpText); - return 0; -} + // Both --help and release-explain report the resolved configuration, so resolution happens + // before either is rendered. It is read-only and mutates nothing. + var startup = ConfigurationChain.ResolveStartup(commandLine); -// Identify the tool up front, so CI job logs and local runs show which build tool executed the pipeline. -CLIConsole.WriteLine(ToolInfo.VersionLine); -CLIConsole.WriteLine(); + if (commandLine.Command == ToolCommand.Help) + { + CLIConsole.WriteLine(ToolInfo.BuildHelpText(startup.Resolution)); + return 0; + } -try -{ - var failures = await BuildPipeline.RunAsync(args); + if (commandLine.Command == ToolCommand.ReleaseExplain) + { + var configuration = ConfigurationChain.Build(startup, commandLine.PipelineArguments); + var explanation = await ReleaseExplainer.ExplainAsync( + startup, + configuration, + CancellationToken.None + ); + + // Both forms are written raw: the JSON is a contract a workflow pipes into jq, and the text + // report is already wrapped deliberately, so console re-wrapping only mangles it. + CLIConsole.WriteRaw( + commandLine.Format == OutputFormat.Json + ? explanation.ToJson() + : ReleaseExplanationText.Render(explanation) + ); + + // release-explain always exits 0: it reports a decision, it does not act on one. The + // decision's own exit code is in the report, for the caller to act on. + return 0; + } + + // Identify the tool up front, so CI job logs and local runs show which build tool executed the + // pipeline, and which configuration it read. + CLIConsole.WriteLine(ToolInfo.VersionLine); + + foreach (var line in ConfigurationChain.DescribeResolution(startup.Resolution)) + CLIConsole.WriteLine(line); + + foreach (var warning in ConfigurationChain.DescribeWarnings(startup.Resolution)) + CLIConsole.WriteLine(warning); + + CLIConsole.WriteLine(); + + var failures = await BuildPipeline.RunAsync(commandLine, startup); if (failures.Count == 0) return 0; @@ -39,4 +76,4 @@ CLIConsole.WriteLine(); CLIConsole.WriteLine(FailureReport.Format($"{ToolInfo.Name} failed", exception)); return 1; -} \ No newline at end of file +} diff --git a/src/src/Build/Release/EligibilityDecision.cs b/src/src/Build/Release/EligibilityDecision.cs new file mode 100644 index 0000000..9757696 --- /dev/null +++ b/src/src/Build/Release/EligibilityDecision.cs @@ -0,0 +1,70 @@ +namespace Purview.Build.Release; + +/// +/// The overall outcome of evaluating a release unit against a policy. +/// +public enum EligibilityVerdict +{ + /// Every enabled rule passed; the release should proceed. + Release, + + /// Already released. Not an error: the run exits 0 and publishes nothing. + Skip, + + /// A policy violation. The run exits 1. + Fail, +} + +/// +/// The outcome of one REL0nn rule. +/// +public enum RuleOutcome +{ + Pass, + + Skip, + + Fail, +} + +/// +/// One rule's verdict and its message. The message always begins with the rule's ID so every +/// failure path names the rule that produced it. +/// +public sealed record RuleResult(string RuleId, RuleOutcome Outcome, string Message) +{ + public static RuleResult Pass(string ruleId, string message) => + new(ruleId, RuleOutcome.Pass, $"{ruleId}: {message}"); + + public static RuleResult Skip(string ruleId, string message) => + new(ruleId, RuleOutcome.Skip, $"{ruleId}: {message}"); + + public static RuleResult Fail(string ruleId, string message) => + new(ruleId, RuleOutcome.Fail, $"{ruleId}: {message}"); +} + +/// +/// The evaluated decision: the verdict, the rule that decided it, and every rule's result in +/// evaluation order up to the short-circuit point. +/// +public sealed record EligibilityDecision( + EligibilityVerdict Verdict, + string? RuleId, + string Message, + IReadOnlyList Results, + string? ShortCircuitRuleId +) +{ + /// + /// The process exit code this decision implies. is a + /// success: the version is simply already released. + /// + public int ExitCode => Verdict == EligibilityVerdict.Fail ? 1 : 0; + + /// + /// The Release__Mode a workflow should set for this decision, given the mode it intended + /// to use. A non-releasing verdict yields None, so the publish and release modules skip. + /// + public ReleaseMode ResolveMode(ReleaseMode intendedMode) => + Verdict == EligibilityVerdict.Release ? intendedMode : ReleaseMode.None; +} diff --git a/src/src/Build/Release/EligibilityEvaluator.cs b/src/src/Build/Release/EligibilityEvaluator.cs new file mode 100644 index 0000000..c618a3d --- /dev/null +++ b/src/src/Build/Release/EligibilityEvaluator.cs @@ -0,0 +1,70 @@ +namespace Purview.Build.Release; + +/// +/// Evaluates a release unit against a resolved policy, short-circuiting in rule-ID order. +/// +/// +/// This is evaluation only. The decision of what to do with the verdict belongs to the workflow, +/// which reads it and sets Release__Mode; the tool never silently declines to release when it +/// has been asked to. +/// +static class EligibilityEvaluator +{ + public static EligibilityDecision Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(policy); + + List results = []; + + foreach (var ruleId in policy.RuleIds) + { + var rule = EligibilityRules.ById(ruleId); + + // REL001 is what establishes that a version exists at all. Any later rule would have to + // re-check, so a parse failure stops evaluation regardless of rule order. + if (input.Unit is null && !string.Equals(rule.Id, "REL001", StringComparison.Ordinal)) + { + var blocked = RuleResult.Fail( + rule.Id, + $"Not evaluated: the version '{input.RawVersion}' could not be parsed." + ); + results.Add(blocked); + + return Decide(EligibilityVerdict.Fail, blocked, results); + } + + var result = rule.Evaluate(input, policy); + results.Add(result); + + if (result.Outcome == RuleOutcome.Skip) + return Decide(EligibilityVerdict.Skip, result, results); + + if (result.Outcome == RuleOutcome.Fail) + return Decide(EligibilityVerdict.Fail, result, results); + } + + return new EligibilityDecision( + Verdict: EligibilityVerdict.Release, + RuleId: null, + Message: policy.RuleIds.Count == 0 + ? $"Policy '{policy.Name}' enables no rules; the release is eligible by default." + : $"Every rule passed ({string.Join(", ", policy.RuleIds)}). The release is eligible.", + Results: results, + ShortCircuitRuleId: null + ); + } + + static EligibilityDecision Decide( + EligibilityVerdict verdict, + RuleResult deciding, + List results + ) => + new( + Verdict: verdict, + RuleId: deciding.RuleId, + Message: deciding.Message, + Results: results, + ShortCircuitRuleId: deciding.RuleId + ); +} diff --git a/src/src/Build/Release/EligibilityInput.cs b/src/src/Build/Release/EligibilityInput.cs new file mode 100644 index 0000000..3ba94bb --- /dev/null +++ b/src/src/Build/Release/EligibilityInput.cs @@ -0,0 +1,64 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release; + +/// +/// Everything one evaluation needs: the raw version as written, the release unit it produced (null +/// when it would not parse), the strictness it is judged by, and the context it is judged against. +/// +/// +/// The raw string is carried separately from because REL001 judges the +/// text — four-part and one/two-part forms parse as but are not SemVer 2, +/// and the distinction is lost once parsed. +/// +public sealed record EligibilityInput( + string RawVersion, + ReleaseUnit? Unit, + VersionStrictness Strictness, + ReleaseContext Context, + ReleaseChannelSettings Channel +) +{ + /// + /// Builds an input the way the pipeline does, deriving the unit from the raw version when it + /// parses at all. + /// + public static EligibilityInput Create( + string rawVersion, + VersionStrictness strictness, + ReleaseContext context, + string channelName, + ReleaseChannelSettings channel, + string versionSource + ) + { + ArgumentNullException.ThrowIfNull(context); + + var unit = ReleaseUnitFactory.TryCreate(rawVersion, channelName, versionSource); + + return new(rawVersion, unit, strictness, context, channel); + } + + /// + /// The minimal input the characterisation tests use: default strictness, default channel, and a + /// ref with a set of existing tags. + /// + public static EligibilityInput ForCharacterisation( + string rawVersion, + string gitRef, + params string[] existingTags + ) => + Create( + rawVersion, + VersionStrictness.NuGet, + ReleaseContext.ForRef(gitRef, "test", existingTags), + ReleaseChannelSettings.StableChannelName, + ReleaseChannelSettings.Stable, + nameof(VersionSource.PackageJson) + ); + + /// + /// True when the raw version has four numeric components, for example 2.0.1.1. + /// + public bool IsFourPart => ReleaseUnitFactory.IsFourPart(RawVersion); +} diff --git a/src/src/Build/Release/EligibilityPolicies.cs b/src/src/Build/Release/EligibilityPolicies.cs new file mode 100644 index 0000000..6fb9d91 --- /dev/null +++ b/src/src/Build/Release/EligibilityPolicies.cs @@ -0,0 +1,204 @@ +namespace Purview.Build.Release; + +/// +/// The built-in eligibility policies, and resolution of a configured policy name into a +/// . +/// +/// +/// The three policies named in the release design ship built in rather than having to be declared in +/// every repository, so adopting a different release model is a single +/// Release:Eligibility:Policy value plus the caller's on: block. A repository may add +/// its own policies, or override a built-in by declaring the same name. +/// +static class EligibilityPolicies +{ + public const string ReleaseOnMainName = "ReleaseOnMain"; + + public const string TrunkReservesMinorName = "TrunkReservesMinor"; + + public const string FourPartServicingName = "FourPartServicing"; + + /// + /// Policies shipped with the tool. + /// + /// + /// is the default and reproduces the behaviour the pipeline had + /// before eligibility rules existed: the version must parse, must not already be released, and a + /// stable version comes from main. It deliberately omits REL004-REL007 so patch releases, + /// version regressions and four-part versions keep behaving exactly as they do today. + /// + public static IReadOnlyDictionary BuiltIn { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ReleaseOnMainName] = new() + { + Rules = ["REL001", "REL002", "REL003"], + // Both documented pre-eligibility release heads. Model A releases from main; the + // main-as-head model merges main into `release` and releases from there. Listing only + // main would make REL003 reject a Model B release, which no repository configures + // its way out of because it configures no eligibility section at all. + StableRefs = ["refs/heads/main", "refs/heads/release"], + TrunkRefs = ["refs/heads/main"], + }, + [TrunkReservesMinorName] = new() + { + Rules = ["REL001", "REL002", "REL003", "REL004", "REL005"], + StableRefs = ["refs/heads/release/*"], + ServicingRefs = ["refs/heads/release/*"], + TrunkRefs = ["refs/heads/main"], + }, + [FourPartServicingName] = new() + { + Inherits = TrunkReservesMinorName, + Rules = ["+REL006"], + AllowFourPart = true, + FourPartRefs = ["refs/heads/release/*"], + }, + }; + + /// + /// Resolves against the built-in policies merged with the + /// repository's own. + /// + /// + /// The policy, or a policy it inherits from, does not exist; the inheritance chain is cyclic; or + /// a rule list mixes absolute entries with +/- deltas. + /// + public static ResolvedPolicy Resolve(EligibilitySettings settings, string policyName) + { + ArgumentNullException.ThrowIfNull(settings); + + var all = Merge(settings.Policies); + + List chain = []; + var resolved = Resolve(all, policyName, chain); + + return resolved; + } + + static Dictionary Merge( + Dictionary configured + ) + { + Dictionary all = new(BuiltIn, StringComparer.OrdinalIgnoreCase); + + foreach (var (name, policy) in configured) + all[name] = policy; + + return all; + } + + static ResolvedPolicy Resolve( + Dictionary all, + string policyName, + List chain + ) + { + if (string.IsNullOrWhiteSpace(policyName)) + throw new InvalidOperationException( + "Release:Eligibility:Policy is empty. Name a policy, or remove the key to use the default " + + $"'{ReleaseOnMainName}' policy." + ); + + if (chain.Contains(policyName, StringComparer.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"The eligibility policy '{policyName}' inherits from itself: " + + $"{string.Join(" -> ", chain)} -> {policyName}." + ); + + if (!all.TryGetValue(policyName, out var policy)) + throw new InvalidOperationException( + $"The eligibility policy '{policyName}' is not defined. Available policies: " + + $"{string.Join(", ", all.Keys.Order(StringComparer.Ordinal))}." + ); + + chain.Add(policyName); + + ResolvedPolicy? parent = null; + if (!string.IsNullOrWhiteSpace(policy.Inherits)) + { + if (!all.ContainsKey(policy.Inherits)) + throw new InvalidOperationException( + $"The eligibility policy '{policyName}' inherits from '{policy.Inherits}', which is not " + + $"defined. Available policies: {string.Join(", ", all.Keys.Order(StringComparer.Ordinal))}." + ); + + parent = Resolve(all, policy.Inherits, chain); + } + + var ruleIds = ApplyRules(policyName, policy.Rules, parent?.RuleIds ?? []); + + return new ResolvedPolicy( + Name: policyName, + RuleIds: ruleIds, + StableRefs: Inherit(policy.StableRefs, parent?.StableRefs), + ServicingRefs: Inherit(policy.ServicingRefs, parent?.ServicingRefs), + TrunkRefs: Inherit(policy.TrunkRefs, parent?.TrunkRefs), + FourPartRefs: Inherit(policy.FourPartRefs, parent?.FourPartRefs), + AllowFourPart: policy.AllowFourPart || (parent?.AllowFourPart ?? false), + InheritanceChain: [.. chain] + ); + } + + /// + /// An empty list on the child means "inherit"; a non-empty list replaces the parent's, so a + /// child can narrow a ref set rather than only widening it. + /// + static IReadOnlyList Inherit(string[] own, IReadOnlyList? parent) => + own.Length > 0 ? own : parent ?? []; + + static IReadOnlyList ApplyRules( + string policyName, + string[] rules, + IReadOnlyList inherited + ) + { + if (rules.Length == 0) + return [.. inherited.Order(StringComparer.Ordinal)]; + + var deltas = rules.Count(IsDelta); + if (deltas != 0 && deltas != rules.Length) + throw new InvalidOperationException( + $"The eligibility policy '{policyName}' mixes absolute rule entries with '+'/'-' deltas " + + $"({string.Join(", ", rules)}). Use either an absolute list or deltas, not both." + ); + + SortedSet resolved = deltas == 0 + ? new(StringComparer.Ordinal) + : new(inherited, StringComparer.Ordinal); + + foreach (var rule in rules) + { + var trimmed = rule.Trim(); + if (trimmed.Length == 0) + continue; + + if (trimmed.StartsWith('-')) + resolved.Remove(Normalize(policyName, trimmed[1..])); + else if (trimmed.StartsWith('+')) + resolved.Add(Normalize(policyName, trimmed[1..])); + else + resolved.Add(Normalize(policyName, trimmed)); + } + + return [.. resolved]; + } + + static bool IsDelta(string rule) + { + var trimmed = rule.Trim(); + return trimmed.StartsWith('+') || trimmed.StartsWith('-'); + } + + static string Normalize(string policyName, string ruleId) + { + var trimmed = ruleId.Trim(); + + return EligibilityRules.IsKnown(trimmed) + ? trimmed.ToUpperInvariant() + : throw new InvalidOperationException( + $"The eligibility policy '{policyName}' names an unknown rule '{trimmed}'. Known rules: " + + $"{string.Join(", ", EligibilityRules.KnownIds)}." + ); + } +} diff --git a/src/src/Build/Release/EligibilityRules.cs b/src/src/Build/Release/EligibilityRules.cs new file mode 100644 index 0000000..632fea5 --- /dev/null +++ b/src/src/Build/Release/EligibilityRules.cs @@ -0,0 +1,33 @@ +using Purview.Build.Release.Rules; + +namespace Purview.Build.Release; + +/// +/// The rule registry. Adding a rule means adding one implementation and one entry here; policies +/// then select it by ID. +/// +static class EligibilityRules +{ + /// + /// Every rule, ordered by ID. Evaluation order is ID order, so the registry's order is the + /// documented order. + /// + public static IReadOnlyList All { get; } = + [ + new Rel001VersionParsesRule(), + new Rel002NotAlreadyReleasedRule(), + new Rel003StableFromStableRefRule(), + new Rel004ServicingPatchRule(), + new Rel005MonotonicVersionRule(), + new Rel006FourPartVersionRule(), + new Rel007PrereleaseLabelRule(), + ]; + + public static IReadOnlyList KnownIds { get; } = [.. All.Select(rule => rule.Id)]; + + public static bool IsKnown(string ruleId) => + KnownIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase); + + public static IEligibilityRule ById(string ruleId) => + All.First(rule => string.Equals(rule.Id, ruleId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/src/Build/Release/IEligibilityRule.cs b/src/src/Build/Release/IEligibilityRule.cs new file mode 100644 index 0000000..363c17d --- /dev/null +++ b/src/src/Build/Release/IEligibilityRule.cs @@ -0,0 +1,16 @@ +namespace Purview.Build.Release; + +/// +/// One release-eligibility rule. Rule IDs are permanent identifiers: never renumbered, never +/// repurposed. +/// +interface IEligibilityRule +{ + /// The rule's permanent REL0nn identifier. + string Id { get; } + + /// One-line description, used by release-explain and the documentation. + string Description { get; } + + RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy); +} diff --git a/src/src/Build/Release/RefMatcher.cs b/src/src/Build/Release/RefMatcher.cs new file mode 100644 index 0000000..8fce55d --- /dev/null +++ b/src/src/Build/Release/RefMatcher.cs @@ -0,0 +1,45 @@ +namespace Purview.Build.Release; + +/// +/// Matches a git ref against the patterns in an eligibility policy. +/// +/// +/// Deliberately narrow: an exact match, or a single trailing * wildcard covering the rest of +/// the ref (so refs/heads/release/* matches refs/heads/release/2.0 and +/// refs/heads/release/2.0/hotfix). Comparison is ordinal and case-sensitive, because git refs +/// are. +/// +static class RefMatcher +{ + public static bool Matches(string? gitRef, IReadOnlyList patterns) + { + if (string.IsNullOrWhiteSpace(gitRef) || patterns.Count == 0) + return false; + + foreach (var pattern in patterns) + { + if (MatchesPattern(gitRef, pattern)) + return true; + } + + return false; + } + + static bool MatchesPattern(string gitRef, string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + return false; + + if (!pattern.EndsWith('*')) + return string.Equals(gitRef, pattern, StringComparison.Ordinal); + + var prefix = pattern[..^1]; + return gitRef.StartsWith(prefix, StringComparison.Ordinal); + } + + /// + /// Renders a pattern list for a rule message, so a failure says what was expected. + /// + public static string Describe(IReadOnlyList patterns) => + patterns.Count == 0 ? "(none configured)" : string.Join(", ", patterns); +} diff --git a/src/src/Build/Release/ReleaseContext.cs b/src/src/Build/Release/ReleaseContext.cs new file mode 100644 index 0000000..1a5ed75 --- /dev/null +++ b/src/src/Build/Release/ReleaseContext.cs @@ -0,0 +1,58 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release; + +/// +/// The outside world an eligibility evaluation is judged against: which ref is being released, and +/// which versions already exist. +/// +/// The git ref being evaluated. +/// Where came from, for reporting. +/// Every tag that already exists. +/// Versions already on the target feed. +/// +/// False when the feed was not consulted (no network, or no simulated list supplied). Rules that +/// depend on feed state then report why they could not judge rather than passing silently. +/// +/// +/// True when any Release:Context:* key supplied this context. A simulated verdict must never +/// be mistakable for a real one, so this is surfaced in every report. +/// +public sealed record ReleaseContext( + string Ref, + string RefSource, + IReadOnlyList ExistingTags, + IReadOnlyList PublishedVersions, + bool PublishedVersionsKnown, + bool Simulated +) +{ + public static ReleaseContext ForRef(string gitRef, string refSource, params string[] existingTags) => + new(gitRef, refSource, existingTags, [], PublishedVersionsKnown: false, Simulated: false); + + /// + /// Whether a tag already exists, compared ordinally — the same comparison + /// git rev-parse "$TAG" effectively performed in the workflow this replaces. + /// + public bool HasTag(string tag) => ExistingTags.Contains(tag, StringComparer.Ordinal); + + /// + /// Every known version, from tags that look like v{version} plus the feed when known. + /// Used by the monotonic-version rule. + /// + public IReadOnlyList KnownVersions() + { + List versions = [.. PublishedVersions]; + + foreach (var tag in ExistingTags) + { + if (!tag.StartsWith('v')) + continue; + + if (NuGetVersion.TryParse(tag[1..], out var version)) + versions.Add(version); + } + + return versions; + } +} diff --git a/src/src/Build/Release/ReleaseContextProvider.cs b/src/src/Build/Release/ReleaseContextProvider.cs new file mode 100644 index 0000000..c76265d --- /dev/null +++ b/src/src/Build/Release/ReleaseContextProvider.cs @@ -0,0 +1,192 @@ +using NuGet.Versioning; +using System.Diagnostics; +using System.Text.Json; + +namespace Purview.Build.Release; + +/// +/// Builds the an evaluation is judged against. +/// +/// +/// Every input can be supplied through Release:Context:* so eligibility is answerable on a +/// developer machine, offline, without being on the branch. When any of them is supplied, no process +/// is launched and no network call is made, and the context is marked simulated so a report can +/// never present a simulated verdict as a real one. +/// +/// The feed half of REL002 is only evaluated from a supplied +/// Release:Context:PublishedVersions list. Querying the feed is deliberately not done: the +/// gate this replaces only ever checked the tag, publication is already idempotent +/// (--skip-duplicate), and a mandatory feed query would make the offline scenario matrices +/// impossible. +/// +static class ReleaseContextProvider +{ + public static ReleaseContext Resolve(ReleaseContextSettings settings, string repositoryRoot) + { + ArgumentNullException.ThrowIfNull(settings); + + var simulated = settings.IsSimulated; + + var (gitRef, refSource) = ResolveRef(settings, repositoryRoot, simulated); + var tags = ResolveTags(settings, repositoryRoot, simulated); + var (publishedVersions, publishedKnown) = ResolvePublishedVersions(settings); + + return new ReleaseContext( + Ref: gitRef, + RefSource: refSource, + ExistingTags: tags, + PublishedVersions: publishedVersions, + PublishedVersionsKnown: publishedKnown, + Simulated: simulated + ); + } + + static (string Ref, string Source) ResolveRef( + ReleaseContextSettings settings, + string repositoryRoot, + bool simulated + ) + { + if (!string.IsNullOrWhiteSpace(settings.Ref)) + return (Normalize(settings.Ref), "Release:Context:Ref (simulated)"); + + var githubRef = Environment.GetEnvironmentVariable("GITHUB_REF"); + if (!string.IsNullOrWhiteSpace(githubRef)) + return (githubRef, "GITHUB_REF"); + + // A simulated evaluation must not shell out, so an unspecified ref stays unspecified + // rather than silently picking up the developer's current branch. + if (simulated) + return (string.Empty, "unspecified (simulated context supplied no ref)"); + + var branch = RunGit(repositoryRoot, "rev-parse", "--abbrev-ref", "HEAD"); + + return string.IsNullOrWhiteSpace(branch) + ? (string.Empty, "unknown (not a git repository, and GITHUB_REF is unset)") + : (Normalize(branch.Trim()), "the current git branch"); + } + + static string[] ResolveTags( + ReleaseContextSettings settings, + string repositoryRoot, + bool simulated + ) + { + if (!string.IsNullOrWhiteSpace(settings.ExistingTags)) + return ReadList(settings.ExistingTags, "Release:Context:ExistingTags"); + + if (simulated) + return []; + + var output = RunGit(repositoryRoot, "tag", "--list"); + + return string.IsNullOrWhiteSpace(output) + ? [] + : [ + .. output.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ), + ]; + } + + static (IReadOnlyList Versions, bool Known) ResolvePublishedVersions( + ReleaseContextSettings settings + ) + { + if (string.IsNullOrWhiteSpace(settings.PublishedVersions)) + return ([], false); + + var entries = ReadList(settings.PublishedVersions, "Release:Context:PublishedVersions"); + + List versions = []; + foreach (var entry in entries) + { + if (NuGetVersion.TryParse(entry, out var version)) + versions.Add(version); + } + + return (versions, true); + } + + /// + /// Reads a newline- or JSON-delimited list from a file. + /// + static string[] ReadList(string path, string origin) + { + var fullPath = Path.GetFullPath(path); + + if (!File.Exists(fullPath)) + throw new InvalidOperationException( + $"{origin} points at '{path}', which does not exist (resolved to '{fullPath}')." + ); + + var content = File.ReadAllText(fullPath).Trim(); + if (content.Length == 0) + return []; + + if (content.StartsWith('[')) + { + try + { + return JsonSerializer.Deserialize(content) ?? []; + } + catch (JsonException exception) + { + throw new InvalidOperationException( + $"{origin} points at '{fullPath}', which starts with '[' but is not a valid JSON array " + + $"of strings: {exception.Message}", + exception + ); + } + } + + return [ + .. content.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ), + ]; + } + + static string Normalize(string gitRef) => + gitRef.StartsWith("refs/", StringComparison.Ordinal) ? gitRef : $"refs/heads/{gitRef}"; + + /// + /// Runs git and returns stdout, or null when git is unavailable or the command failed. + /// + /// + /// Failure is not an error: eligibility is answerable without git (every input can be supplied), + /// and the rules report what they could not determine rather than guessing. + /// + static string? RunGit(string workingDirectory, params string[] arguments) + { + try + { + ProcessStartInfo startInfo = new("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo); + if (process is null) + return null; + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + return process.ExitCode == 0 ? output : null; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return null; + } + } +} diff --git a/src/src/Build/Release/ReleaseExplainer.cs b/src/src/Build/Release/ReleaseExplainer.cs new file mode 100644 index 0000000..a28ba4c --- /dev/null +++ b/src/src/Build/Release/ReleaseExplainer.cs @@ -0,0 +1,187 @@ +using Purview.Build.Configuration; +using Purview.Build.Version; + +namespace Purview.Build.Release; + +/// +/// Composes a from a run's configuration, without running any +/// module or mutating anything. +/// +static class ReleaseExplainer +{ + public static async Task ExplainAsync( + PipelineStartup startup, + IConfiguration configuration, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(startup); + ArgumentNullException.ThrowIfNull(configuration); + + var release = + configuration.GetSection(ReleaseSettings.SectionName).Get() ?? new(); + var version = + configuration.GetSection(VersionSettings.SectionName).Get() ?? new(); + var nuget = configuration.GetSection(NuGetSettings.SectionName).Get() ?? new(); + + var context = ReleaseContextProvider.Resolve(release.Context, startup.RepositoryRoot); + var policy = EligibilityPolicies.Resolve(release.Eligibility, release.Eligibility.Policy); + + var rawVersion = await ReadRawVersionAsync( + version, + startup.RepositoryRoot, + cancellationToken + ); + + var channel = release.ResolveChannel(); + var input = EligibilityInput.Create( + rawVersion, + version.Strictness, + context, + release.Channel, + channel, + version.Source.ToString() + ); + + var decision = EligibilityEvaluator.Evaluate(input, policy); + + return Compose(startup, release, version, nuget, context, policy, input, decision); + } + + /// + /// Reads the version without validating it, so an invalid version is explained by REL001 rather + /// than crashing the explanation. + /// + static async Task ReadRawVersionAsync( + VersionSettings version, + string repositoryRoot, + CancellationToken cancellationToken + ) + { + if (version.Source != VersionSource.PackageJson) + return string.Empty; + + try + { + return await PackageJsonVersionProvider.ReadRawVersionAsync( + repositoryRoot, + cancellationToken + ); + } + catch (Exception exception) when (exception is InvalidOperationException or FileNotFoundException) + { + return string.Empty; + } + } + + static ReleaseExplanation Compose( + PipelineStartup startup, + ReleaseSettings release, + VersionSettings version, + NuGetSettings nuget, + ReleaseContext context, + ResolvedPolicy policy, + EligibilityInput input, + EligibilityDecision decision + ) + { + var mode = decision.ResolveMode(release.Mode); + + return new ReleaseExplanation + { + Verdict = decision.Verdict.ToString(), + ExitCode = decision.ExitCode, + ReleaseMode = mode.ToString(), + Message = decision.Message, + DecidedByRule = decision.RuleId, + ShortCircuitedAt = decision.ShortCircuitRuleId, + Simulated = context.Simulated, + Ref = context.Ref, + RefSource = context.RefSource, + Configuration = new ConfigurationReport + { + ResolvedPath = startup.Resolution.Path, + Source = startup.Resolution.Source.ToString(), + ProbeTrail = + [ + .. startup.Resolution.ProbeTrail.Select(probe => new ProbeReport + { + RelativePath = probe.RelativePath, + FullPath = probe.FullPath, + Exists = probe.Exists, + }), + ], + ShadowedPaths = startup.Resolution.ShadowedPaths, + UserConfigPath = startup.Resolution.UserConfigPath, + UserConfigActive = startup.Resolution.UserConfigActive, + }, + Version = new VersionReport + { + Raw = input.RawVersion, + Source = version.Source.ToString(), + Strictness = version.Strictness.ToString(), + Units = input.Unit is null + ? [] + : [ToReport(input.Unit)], + }, + Policy = new PolicyReport + { + Name = policy.Name, + InheritanceChain = policy.InheritanceChain, + ResolvedRules = policy.RuleIds, + StableRefs = policy.StableRefs, + ServicingRefs = policy.ServicingRefs, + TrunkRefs = policy.TrunkRefs, + FourPartRefs = policy.FourPartRefs, + AllowFourPart = policy.AllowFourPart, + }, + Rules = + [ + .. decision.Results.Select(result => new RuleReport + { + Id = result.RuleId, + Verdict = DescribeOutcome(result.Outcome), + Message = result.Message, + }), + ], + Publication = new PublicationReport + { + Mode = mode.ToString(), + // Reported as the publication that WOULD result: a non-releasing verdict yields + // Release__Mode=None, so nothing publishes regardless of the configured preset. + Publish = mode == ReleaseMode.None ? false : release.ShouldPublish(), + GitHubRelease = mode == ReleaseMode.None ? false : release.ShouldCreateGitHubRelease(), + Channel = release.Channel, + FeedUrl = release.ResolveChannel().FeedUrl ?? nuget.FeedUrl, + DryRun = release.DryRun, + }, + Warnings = ConfigurationChain.DescribeWarnings(startup.Resolution), + }; + } + + /// + /// The per-rule verdict strings in the JSON contract. Spelled out rather than lower-cased from + /// the enum name, so the contract cannot drift if the enum is renamed. + /// + static string DescribeOutcome(RuleOutcome outcome) => + outcome switch + { + RuleOutcome.Pass => "pass", + RuleOutcome.Skip => "skip", + RuleOutcome.Fail => "fail", + _ => "unknown", + }; + + static UnitReport ToReport(ReleaseUnit unit) => + new() + { + Id = unit.Id, + Version = unit.Version.ToString(), + IsPrerelease = unit.IsPrerelease, + PrereleaseLabel = unit.PrereleaseLabel, + Line = unit.Line, + Channel = unit.Channel, + Tag = unit.Tag, + Source = unit.Source, + }; +} diff --git a/src/src/Build/Release/ReleaseExplanation.cs b/src/src/Build/Release/ReleaseExplanation.cs new file mode 100644 index 0000000..30286c7 --- /dev/null +++ b/src/src/Build/Release/ReleaseExplanation.cs @@ -0,0 +1,214 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Purview.Build.Release; + +/// +/// Everything release-explain reports, in one shape rendered either as text or as the JSON +/// contract the reusable workflow consumes. +/// +/// +/// The JSON property names are a published contract: the reusable release workflow parses +/// verdict, exitCode and releaseMode. Renaming or removing a property is a +/// breaking change to every consumer pinned to a workflow ref, which is why the shape is pinned by a +/// golden file in the test suite. +/// +sealed record ReleaseExplanation +{ + [JsonPropertyName("verdict")] + public string Verdict { get; init; } = string.Empty; + + [JsonPropertyName("exitCode")] + public int ExitCode { get; init; } + + /// + /// The Release__Mode the workflow should set. None for a skip or a failure, so the + /// publish and release modules skip without the workflow having to reason about it. + /// + [JsonPropertyName("releaseMode")] + public string ReleaseMode { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + [JsonPropertyName("decidedByRule")] + public string? DecidedByRule { get; init; } + + [JsonPropertyName("shortCircuitedAt")] + public string? ShortCircuitedAt { get; init; } + + [JsonPropertyName("simulated")] + public bool Simulated { get; init; } + + [JsonPropertyName("ref")] + public string Ref { get; init; } = string.Empty; + + [JsonPropertyName("refSource")] + public string RefSource { get; init; } = string.Empty; + + [JsonPropertyName("configuration")] + public ConfigurationReport Configuration { get; init; } = new(); + + [JsonPropertyName("version")] + public VersionReport Version { get; init; } = new(); + + [JsonPropertyName("policy")] + public PolicyReport Policy { get; init; } = new(); + + [JsonPropertyName("rules")] + public IReadOnlyList Rules { get; init; } = []; + + [JsonPropertyName("publication")] + public PublicationReport Publication { get; init; } = new(); + + [JsonPropertyName("warnings")] + public IReadOnlyList Warnings { get; init; } = []; + + static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + // Rule messages quote versions and refs with apostrophes. The default encoder escapes those + // to ', which is valid JSON but makes the contract unreadable in a workflow log and in + // the golden file that pins it. This output goes to a console and to jq, never into HTML. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); +} + +sealed record ConfigurationReport +{ + [JsonPropertyName("resolvedPath")] + public string? ResolvedPath { get; init; } + + [JsonPropertyName("source")] + public string Source { get; init; } = "None"; + + [JsonPropertyName("probeTrail")] + public IReadOnlyList ProbeTrail { get; init; } = []; + + [JsonPropertyName("shadowedPaths")] + public IReadOnlyList ShadowedPaths { get; init; } = []; + + [JsonPropertyName("userConfigPath")] + public string? UserConfigPath { get; init; } + + [JsonPropertyName("userConfigActive")] + public bool UserConfigActive { get; init; } +} + +sealed record ProbeReport +{ + [JsonPropertyName("relativePath")] + public string RelativePath { get; init; } = string.Empty; + + [JsonPropertyName("fullPath")] + public string FullPath { get; init; } = string.Empty; + + [JsonPropertyName("exists")] + public bool Exists { get; init; } +} + +sealed record VersionReport +{ + [JsonPropertyName("raw")] + public string Raw { get; init; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; init; } = string.Empty; + + [JsonPropertyName("strictness")] + public string Strictness { get; init; } = string.Empty; + + [JsonPropertyName("units")] + public IReadOnlyList Units { get; init; } = []; +} + +sealed record UnitReport +{ + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("isPrerelease")] + public bool IsPrerelease { get; init; } + + [JsonPropertyName("prereleaseLabel")] + public string? PrereleaseLabel { get; init; } + + [JsonPropertyName("line")] + public string Line { get; init; } = string.Empty; + + [JsonPropertyName("channel")] + public string Channel { get; init; } = string.Empty; + + [JsonPropertyName("tag")] + public string Tag { get; init; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; init; } = string.Empty; +} + +sealed record PolicyReport +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("inheritanceChain")] + public IReadOnlyList InheritanceChain { get; init; } = []; + + [JsonPropertyName("resolvedRules")] + public IReadOnlyList ResolvedRules { get; init; } = []; + + [JsonPropertyName("stableRefs")] + public IReadOnlyList StableRefs { get; init; } = []; + + [JsonPropertyName("servicingRefs")] + public IReadOnlyList ServicingRefs { get; init; } = []; + + [JsonPropertyName("trunkRefs")] + public IReadOnlyList TrunkRefs { get; init; } = []; + + [JsonPropertyName("fourPartRefs")] + public IReadOnlyList FourPartRefs { get; init; } = []; + + [JsonPropertyName("allowFourPart")] + public bool AllowFourPart { get; init; } +} + +sealed record RuleReport +{ + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("verdict")] + public string Verdict { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; +} + +sealed record PublicationReport +{ + [JsonPropertyName("mode")] + public string Mode { get; init; } = string.Empty; + + [JsonPropertyName("publish")] + public bool Publish { get; init; } + + [JsonPropertyName("githubRelease")] + public bool GitHubRelease { get; init; } + + [JsonPropertyName("channel")] + public string Channel { get; init; } = string.Empty; + + [JsonPropertyName("feedUrl")] + public string FeedUrl { get; init; } = string.Empty; + + [JsonPropertyName("dryRun")] + public bool DryRun { get; init; } +} diff --git a/src/src/Build/Release/ReleaseExplanationText.cs b/src/src/Build/Release/ReleaseExplanationText.cs new file mode 100644 index 0000000..c4e1b3b --- /dev/null +++ b/src/src/Build/Release/ReleaseExplanationText.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using System.Text; + +namespace Purview.Build.Release; + +/// +/// Renders a for a human. +/// +static class ReleaseExplanationText +{ + static readonly CultureInfo Culture = CultureInfo.InvariantCulture; + + public static string Render(ReleaseExplanation explanation) + { + ArgumentNullException.ThrowIfNull(explanation); + + StringBuilder builder = new(); + + if (explanation.Simulated) + { + builder.AppendLine( + "SIMULATED: release context was supplied through Release:Context:*. This verdict describes a" + ); + builder.AppendLine("hypothetical release, not the real state of this repository."); + builder.AppendLine(); + } + + builder.AppendLine(Culture, $"Verdict: {explanation.Verdict} (exit code {explanation.ExitCode})"); + builder.AppendLine(Culture, $" {explanation.Message}"); + builder.AppendLine(); + + AppendConfiguration(builder, explanation.Configuration); + AppendVersion(builder, explanation.Version); + AppendPolicy(builder, explanation.Policy, explanation.Ref, explanation.RefSource); + AppendRules(builder, explanation); + AppendPublication(builder, explanation.Publication); + + if (explanation.Warnings.Count > 0) + { + builder.AppendLine("Warnings:"); + foreach (var warning in explanation.Warnings) + builder.AppendLine(Culture, $" {warning}"); + + builder.AppendLine(); + } + + return builder.ToString().TrimEnd(); + } + + static void AppendConfiguration(StringBuilder builder, ConfigurationReport configuration) + { + builder.AppendLine("Configuration:"); + builder.AppendLine( + configuration.ResolvedPath is null + ? " resolved: none found (using built-in defaults)" + : $" resolved: {configuration.ResolvedPath} (via {configuration.Source})" + ); + + if (configuration.ProbeTrail.Count > 0) + { + builder.AppendLine(" probe trail:"); + foreach (var probe in configuration.ProbeTrail) + { + builder.AppendLine( + Culture, + $" [{(probe.Exists ? "found" : " ")}] {probe.RelativePath}" + ); + } + } + + if (configuration.ShadowedPaths.Count > 0) + { + builder.AppendLine(" shadowed (NOT read):"); + foreach (var path in configuration.ShadowedPaths) + builder.AppendLine(Culture, $" {path}"); + } + + builder.AppendLine( + configuration.UserConfigActive + ? $" user config: {configuration.UserConfigPath} (active)" + : " user config: not active" + ); + builder.AppendLine(); + } + + static void AppendVersion(StringBuilder builder, VersionReport version) + { + builder.AppendLine( + Culture, + $"Version: '{version.Raw}' from {version.Source} ({version.Strictness} strictness)" + ); + + if (version.Units.Count == 0) + { + builder.AppendLine(" no release units (the version could not be parsed)"); + } + else + { + builder.AppendLine(" release units:"); + foreach (var unit in version.Units) + { + var label = unit.IsPrerelease + ? $" prerelease label={unit.PrereleaseLabel}" + : string.Empty; + + builder.AppendLine( + Culture, + $" {unit.Id} version={unit.Version} line={unit.Line} channel={unit.Channel} tag={unit.Tag} source={unit.Source}{label}" + ); + } + } + + builder.AppendLine(); + } + + static void AppendPolicy( + StringBuilder builder, + PolicyReport policy, + string gitRef, + string refSource + ) + { + builder.AppendLine(Culture, $"Policy: {policy.Name}"); + + if (policy.InheritanceChain.Count > 1) + builder.AppendLine(Culture, $" inherits: {string.Join(" <- ", policy.InheritanceChain)}"); + + builder.AppendLine(Culture, $" rules: {string.Join(", ", policy.ResolvedRules)}"); + builder.AppendLine(Culture, $" stable refs: {RefMatcher.Describe(policy.StableRefs)}"); + + if (policy.ServicingRefs.Count > 0) + builder.AppendLine(Culture, $" servicing refs: {RefMatcher.Describe(policy.ServicingRefs)}"); + + if (policy.AllowFourPart) + builder.AppendLine(Culture, $" four-part refs: {RefMatcher.Describe(policy.FourPartRefs)}"); + + builder.AppendLine( + Culture, + $" evaluated ref: {(string.IsNullOrEmpty(gitRef) ? "(none)" : gitRef)} (from {refSource})" + ); + builder.AppendLine(); + } + + static void AppendRules(StringBuilder builder, ReleaseExplanation explanation) + { + builder.AppendLine("Rules, in evaluation order:"); + + foreach (var rule in explanation.Rules) + builder.AppendLine(Culture, $" {rule.Verdict,-4} {rule.Message}"); + + if (explanation.ShortCircuitedAt is not null) + { + builder.AppendLine( + Culture, + $" short-circuited at {explanation.ShortCircuitedAt}; later rules were not evaluated." + ); + } + + builder.AppendLine(); + } + + static void AppendPublication(StringBuilder builder, PublicationReport publication) + { + builder.AppendLine("Publication that would result:"); + builder.AppendLine(Culture, $" Release__Mode: {publication.Mode}"); + builder.AppendLine(Culture, $" Release:Publish: {publication.Publish}"); + builder.AppendLine(Culture, $" Release:GitHubRelease: {publication.GitHubRelease}"); + builder.AppendLine(Culture, $" Release:Channel: {publication.Channel}"); + builder.AppendLine(Culture, $" feed URL: {publication.FeedUrl}"); + + if (publication.DryRun) + builder.AppendLine(" Release:DryRun is set, so nothing would actually be pushed or tagged."); + + builder.AppendLine(); + } +} diff --git a/src/src/Build/Release/ReleaseUnit.cs b/src/src/Build/Release/ReleaseUnit.cs new file mode 100644 index 0000000..d8b168e --- /dev/null +++ b/src/src/Build/Release/ReleaseUnit.cs @@ -0,0 +1,51 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release; + +/// +/// One thing that can be released: a version, the line it belongs to, the channel it goes to, and +/// the tag it would carry. +/// +/// +/// Modelled as an ordered set even though every current version source yields exactly one unit, so a +/// future multi-unit source needs no change in PackModule or CreateGitHubReleaseModule. +/// A reference type (not a readonly record struct) because it is passed around as an +/// immutable module result and compared by value in reporting. +/// +public sealed record ReleaseUnit( + string Id, + NuGetVersion Version, + bool IsPrerelease, + string? PrereleaseLabel, + string Line, + string Channel, + string Tag, + string Source +) +{ + /// + /// The release line a version belongs to: its MAJOR.MINOR pair, for example 2.0 for + /// 2.0.2. Two versions on the same line are serviced together. + /// + public static string LineOf(NuGetVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return $"{version.Major}.{version.Minor}"; + } + + /// + /// The prerelease label without its numeric counter, for example prerelease for + /// 2.1.0-prerelease.4. Null for a stable version. + /// + public static string? LabelOf(NuGetVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + if (!version.IsPrerelease || string.IsNullOrEmpty(version.Release)) + return null; + + var firstPart = version.Release.Split('.', StringSplitOptions.RemoveEmptyEntries)[0]; + return firstPart; + } +} diff --git a/src/src/Build/Release/ReleaseUnitFactory.cs b/src/src/Build/Release/ReleaseUnitFactory.cs new file mode 100644 index 0000000..ee59d3a --- /dev/null +++ b/src/src/Build/Release/ReleaseUnitFactory.cs @@ -0,0 +1,58 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release; + +/// +/// Turns a raw version string into a . +/// +/// +/// Shared by the version providers and by eligibility evaluation, so the tag, line, channel and +/// label a rule judges are exactly the ones the pipeline would act on. +/// +static class ReleaseUnitFactory +{ + /// + /// Builds a unit, or null when does not parse at all. + /// + public static ReleaseUnit? TryCreate(string? rawVersion, string channelName, string versionSource) + { + if (string.IsNullOrWhiteSpace(rawVersion) || !NuGetVersion.TryParse(rawVersion, out var version)) + return null; + + // The version is known, so build a unit. The unit's tag is the version exactly as written in + return new ReleaseUnit( + Id: version.ToNormalizedString(), + Version: version, + IsPrerelease: version.IsPrerelease, + PrereleaseLabel: ReleaseUnit.LabelOf(version), + Line: ReleaseUnit.LineOf(version), + Channel: channelName, + // ToString() preserves the version exactly as written in package.json, which is what the + // workflow's `TAG="v$VERSION"` produced. ToNormalizedString() would drop a trailing ".0" + // revision and desynchronise the tag from the declared version. + Tag: $"v{version}", + Source: versionSource + ); + } + + /// + /// Whether a raw version string has four numeric components, for example 2.0.1.1. + /// + public static bool IsFourPart(string? rawVersion) => + !string.IsNullOrWhiteSpace(rawVersion) + && rawVersion.Split('-', '+')[0].Count(character => character == '.') == 3; + + /// + /// Whether a raw version string satisfies the configured strictness. + /// + public static bool SatisfiesStrictness(string? rawVersion, VersionStrictness strictness) + { + if (string.IsNullOrWhiteSpace(rawVersion)) + return false; + + // NuGetVersion and SemanticVersion are both strict parsers, so the only difference is that + return strictness == VersionStrictness.SemVer2 + ? SemanticVersion.TryParse(rawVersion, out _) + : NuGetVersion.TryParse(rawVersion, out _); + } +} diff --git a/src/src/Build/Release/ReleaseUnitSet.cs b/src/src/Build/Release/ReleaseUnitSet.cs new file mode 100644 index 0000000..ddf998e --- /dev/null +++ b/src/src/Build/Release/ReleaseUnitSet.cs @@ -0,0 +1,28 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release; + +/// +/// The ordered set of release units a run produces, resolved once and read by every downstream +/// module. +/// +/// +/// Every current version source yields exactly one unit, but the set is the contract so adding a +/// second requires no change in PackModule or CreateGitHubReleaseModule. +/// +public sealed record ReleaseUnitSet(string RawVersion, IReadOnlyList Units, string Source) +{ + /// + /// The first unit. Modules that pack or tag a single version use this; modules that must handle + /// every unit enumerate . + /// + public ReleaseUnit Primary => + Units.Count > 0 + ? Units[0] + : throw new InvalidOperationException("The version source produced no release units."); + + /// + /// The primary unit's version, for the many call sites that only need the version. + /// + public NuGetVersion Version => Primary.Version; +} diff --git a/src/src/Build/Release/ResolvedPolicy.cs b/src/src/Build/Release/ResolvedPolicy.cs new file mode 100644 index 0000000..952788e --- /dev/null +++ b/src/src/Build/Release/ResolvedPolicy.cs @@ -0,0 +1,19 @@ +namespace Purview.Build.Release; + +/// +/// A policy with its fully resolved: inheritance chains followed and +/// +/- deltas applied, ordered by rule ID so evaluation order is the documented one. +/// +public sealed record ResolvedPolicy( + string Name, + IReadOnlyList RuleIds, + IReadOnlyList StableRefs, + IReadOnlyList ServicingRefs, + IReadOnlyList TrunkRefs, + IReadOnlyList FourPartRefs, + bool AllowFourPart, + IReadOnlyList InheritanceChain +) +{ + public bool Enables(string ruleId) => RuleIds.Contains(ruleId, StringComparer.Ordinal); +} diff --git a/src/src/Build/Release/Rules/Rel001VersionParsesRule.cs b/src/src/Build/Release/Rules/Rel001VersionParsesRule.cs new file mode 100644 index 0000000..518219a --- /dev/null +++ b/src/src/Build/Release/Rules/Rel001VersionParsesRule.cs @@ -0,0 +1,39 @@ +using NuGet.Versioning; + +namespace Purview.Build.Release.Rules; + +/// +/// REL001 — the version parses as valid for the configured strictness. +/// +sealed class Rel001VersionParsesRule : IEligibilityRule +{ + public string Id => "REL001"; + + public string Description => "Version parses as valid for the configured strictness"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + + if (string.IsNullOrWhiteSpace(input.RawVersion)) + return RuleResult.Fail(Id, "The version in package.json is missing or empty."); + + if (input.Unit is null) + return RuleResult.Fail( + Id, + $"The version '{input.RawVersion}' is not a valid version. " + + "Set the 'version' field in the repository root package.json to a valid SemVer value." + ); + + if (input.Strictness == VersionStrictness.SemVer2 && !SemanticVersion.TryParse(input.RawVersion, out _)) + return RuleResult.Fail( + Id, + $"The version '{input.RawVersion}' is not valid SemVer 2.0, and Version:Strictness is " + + "SemVer2. Use a three-part version, or set Version:Strictness=NuGet to allow " + + "four-part versions." + ); + + // The version is known and satisfies the configured strictness. + return RuleResult.Pass(Id, $"Version '{input.RawVersion}' is valid for {input.Strictness} strictness."); + } +} diff --git a/src/src/Build/Release/Rules/Rel002NotAlreadyReleasedRule.cs b/src/src/Build/Release/Rules/Rel002NotAlreadyReleasedRule.cs new file mode 100644 index 0000000..81b9342 --- /dev/null +++ b/src/src/Build/Release/Rules/Rel002NotAlreadyReleasedRule.cs @@ -0,0 +1,56 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL002 — v{version} is not already tagged, and the version is not already on the target +/// feed. +/// +/// +/// This is the rule that replaces the workflow's git rev-parse "$TAG" gate, so a hit is a +/// (already released; exit 0), never a failure. The feed half is only +/// judged when the feed state is known: offline evaluation reports that it could not be checked +/// rather than passing silently. +/// +sealed class Rel002NotAlreadyReleasedRule : IEligibilityRule +{ + public string Id => "REL002"; + + public string Description => + "v{version} is not already tagged, and the version is not already on the target feed"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + + var unit = input.Unit!; + + if (input.Context.HasTag(unit.Tag)) + return RuleResult.Skip( + Id, + $"Version {unit.Version} is already released as {unit.Tag}. Nothing to do." + ); + + if (input.Context.PublishedVersionsKnown) + { + var published = input.Context.PublishedVersions.Any(version => + version.Equals(unit.Version) + ); + + if (published) + return RuleResult.Skip( + Id, + $"Version {unit.Version} is already on the target feed. Nothing to do." + ); + + // The version is not on the feed, and the tag does not exist, so the release is eligible. + return RuleResult.Pass( + Id, + $"{unit.Tag} does not exist and {unit.Version} is not on the target feed." + ); + } + + return RuleResult.Pass( + Id, + $"{unit.Tag} does not exist. The feed was not consulted, so only the tag was checked." + ); + } +} diff --git a/src/src/Build/Release/Rules/Rel003StableFromStableRefRule.cs b/src/src/Build/Release/Rules/Rel003StableFromStableRefRule.cs new file mode 100644 index 0000000..1de5a2a --- /dev/null +++ b/src/src/Build/Release/Rules/Rel003StableFromStableRefRule.cs @@ -0,0 +1,45 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL003 — a stable (non-prerelease) version originates only from a ref in StableRefs. +/// +/// +/// Prerelease versions are unconstrained by this rule, which is what keeps repositories that ship +/// prereleases from main (sourcegenerator-framework, value-objects, zodsharp) unaffected. +/// +sealed class Rel003StableFromStableRefRule : IEligibilityRule +{ + public string Id => "REL003"; + + public string Description => + "A stable (non-prerelease) version originates only from a ref in StableRefs"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(policy); + + var unit = input.Unit!; + + if (unit.IsPrerelease) + return RuleResult.Pass( + Id, + $"Version {unit.Version} is a prerelease, which this rule does not constrain." + ); + + if (RefMatcher.Matches(input.Context.Ref, policy.StableRefs)) + return RuleResult.Pass( + Id, + $"Stable version {unit.Version} is released from '{input.Context.Ref}'." + ); + + // The ref does not match the policy's allowed refs for stable releases. + return RuleResult.Fail( + Id, + $"Stable version {unit.Version} cannot be released from '{input.Context.Ref}'. " + + $"Policy '{policy.Name}' allows stable releases only from: " + + $"{RefMatcher.Describe(policy.StableRefs)}. Release a prerelease version from this ref, " + + "or merge into an allowed ref." + ); + } +} diff --git a/src/src/Build/Release/Rules/Rel004ServicingPatchRule.cs b/src/src/Build/Release/Rules/Rel004ServicingPatchRule.cs new file mode 100644 index 0000000..9284489 --- /dev/null +++ b/src/src/Build/Release/Rules/Rel004ServicingPatchRule.cs @@ -0,0 +1,46 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL004 — a non-zero PATCH component originates only from a ref matching ServicingRefs. +/// +/// +/// This is the minor-reservation rule: trunk cuts x.y.0, and x.y.1 onwards is serviced +/// from the line's own release branch. Default-disabled, so Model A repositories keep shipping +/// patches from main. +/// +sealed class Rel004ServicingPatchRule : IEligibilityRule +{ + public string Id => "REL004"; + + public string Description => + "A non-zero PATCH component originates only from a ref matching ServicingRefs"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(policy); + + var unit = input.Unit!; + + if (unit.Version.Patch == 0) + return RuleResult.Pass( + Id, + $"Version {unit.Version} has a zero PATCH component, which this rule does not constrain." + ); + + if (RefMatcher.Matches(input.Context.Ref, policy.ServicingRefs)) + return RuleResult.Pass( + Id, + $"Servicing version {unit.Version} is released from '{input.Context.Ref}'." + ); + + // The ref does not match the policy's allowed refs for servicing releases. + return RuleResult.Fail( + Id, + $"Version {unit.Version} has a non-zero PATCH component and cannot be released from " + + $"'{input.Context.Ref}'. Policy '{policy.Name}' allows servicing releases only from: " + + $"{RefMatcher.Describe(policy.ServicingRefs)}. Cut the patch from the " + + $"release/{unit.Line} branch instead." + ); + } +} diff --git a/src/src/Build/Release/Rules/Rel005MonotonicVersionRule.cs b/src/src/Build/Release/Rules/Rel005MonotonicVersionRule.cs new file mode 100644 index 0000000..7d97a94 --- /dev/null +++ b/src/src/Build/Release/Rules/Rel005MonotonicVersionRule.cs @@ -0,0 +1,49 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL005 — the version is strictly greater than the highest existing version on the same line. +/// +/// +/// Scoped to the line (MAJOR.MINOR) so a serviced stable line and an in-flight prerelease line can +/// advance independently without either blocking the other. Default-disabled, because the pipeline +/// has never enforced it. +/// +sealed class Rel005MonotonicVersionRule : IEligibilityRule +{ + public string Id => "REL005"; + + public string Description => + "Version is strictly greater than the highest existing version on the same line"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + + var unit = input.Unit!; + + var highest = input + .Context.KnownVersions() + .Where(version => string.Equals(ReleaseUnit.LineOf(version), unit.Line, StringComparison.Ordinal)) + .Order() + .LastOrDefault(); + + if (highest is null) + return RuleResult.Pass( + Id, + $"No existing version on line {unit.Line}; {unit.Version} is the first." + ); + + if (unit.Version > highest) + return RuleResult.Pass( + Id, + $"Version {unit.Version} is greater than {highest} on line {unit.Line}." + ); + + // The version is not greater than the highest existing version on the same line. + return RuleResult.Fail( + Id, + $"Version {unit.Version} is not greater than the highest existing version {highest} on line " + + $"{unit.Line}. Bump the version in package.json past {highest}." + ); + } +} diff --git a/src/src/Build/Release/Rules/Rel006FourPartVersionRule.cs b/src/src/Build/Release/Rules/Rel006FourPartVersionRule.cs new file mode 100644 index 0000000..4b541ef --- /dev/null +++ b/src/src/Build/Release/Rules/Rel006FourPartVersionRule.cs @@ -0,0 +1,53 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL006 — a four-part version is permitted only when AllowFourPart is set and the ref +/// matches FourPartRefs. +/// +/// +/// An escape hatch, not a routine flow: MinVer, release-please and changesets cannot produce a +/// four-part version, so this path is only ever reached by a hand-edited package.json. +/// Default-disabled, so the two repositories that ship four-part versions today are unaffected. +/// +sealed class Rel006FourPartVersionRule : IEligibilityRule +{ + public string Id => "REL006"; + + public string Description => + "A four-part version is permitted only when AllowFourPart and the ref matches FourPartRefs"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(policy); + + var unit = input.Unit!; + + if (!input.IsFourPart) + return RuleResult.Pass( + Id, + $"Version {unit.Version} is not a four-part version, which this rule does not constrain." + ); + + if (!policy.AllowFourPart) + return RuleResult.Fail( + Id, + $"Version {input.RawVersion} is a four-part version, which policy '{policy.Name}' does not " + + "permit. Set AllowFourPart on the policy, or use a three-part version." + ); + + if (RefMatcher.Matches(input.Context.Ref, policy.FourPartRefs)) + return RuleResult.Pass( + Id, + $"Four-part version {input.RawVersion} is released from '{input.Context.Ref}'." + ); + + // The ref does not match the policy's allowed refs for four-part releases. + return RuleResult.Fail( + Id, + $"Four-part version {input.RawVersion} cannot be released from '{input.Context.Ref}'. " + + $"Policy '{policy.Name}' allows four-part releases only from: " + + $"{RefMatcher.Describe(policy.FourPartRefs)}." + ); + } +} diff --git a/src/src/Build/Release/Rules/Rel007PrereleaseLabelRule.cs b/src/src/Build/Release/Rules/Rel007PrereleaseLabelRule.cs new file mode 100644 index 0000000..f556420 --- /dev/null +++ b/src/src/Build/Release/Rules/Rel007PrereleaseLabelRule.cs @@ -0,0 +1,53 @@ +namespace Purview.Build.Release.Rules; + +/// +/// REL007 — the prerelease label matches the channel's allowed pattern. +/// +/// +/// Keeps a channel's labels consistent, so a preview channel cannot accidentally ship +/// -alpha.1. Default-disabled, and a no-op when the channel declares no +/// LabelPattern. +/// +sealed class Rel007PrereleaseLabelRule : IEligibilityRule +{ + public string Id => "REL007"; + + public string Description => "The prerelease label matches the channel's allowed pattern"; + + public RuleResult Evaluate(EligibilityInput input, ResolvedPolicy policy) + { + ArgumentNullException.ThrowIfNull(input); + + var unit = input.Unit!; + var pattern = input.Channel.LabelPattern; + + if (!unit.IsPrerelease) + return RuleResult.Pass( + Id, + $"Version {unit.Version} is stable, so it carries no prerelease label." + ); + + if (string.IsNullOrWhiteSpace(pattern)) + return RuleResult.Pass( + Id, + $"Channel '{unit.Channel}' declares no LabelPattern, so any label is allowed." + ); + + var label = unit.PrereleaseLabel ?? string.Empty; + var matches = pattern.EndsWith('*') + ? label.StartsWith(pattern[..^1], StringComparison.Ordinal) + : string.Equals(label, pattern, StringComparison.Ordinal); + + return matches + ? RuleResult.Pass( + Id, + $"Prerelease label '{label}' matches channel '{unit.Channel}' pattern '{pattern}'." + ) + : RuleResult.Fail( + Id, + $"Prerelease label '{label}' does not match the '{unit.Channel}' channel's LabelPattern " + + $"'{pattern}'. Rename the prerelease label in package.json, or release on a channel " + + "that allows it." + ); + } +} diff --git a/src/src/Build/Settings/EligibilitySettings.cs b/src/src/Build/Settings/EligibilitySettings.cs new file mode 100644 index 0000000..78e4ce2 --- /dev/null +++ b/src/src/Build/Settings/EligibilitySettings.cs @@ -0,0 +1,71 @@ +namespace Purview.Build.Settings; + +/// +/// One named release-eligibility policy: which REL0nn rules apply, and the refs they judge +/// against. +/// +/// +/// is either an absolute list (["REL001", "REL002"]) or a list of deltas +/// against (["+REL006", "-REL005"]). Mixing the two forms in one list +/// is a configuration error, because the intended precedence would not be obvious. +/// +public sealed record EligibilityPolicySettings +{ + /// + /// Name of the policy this one is expressed as a diff from. A missing parent is an error naming + /// the unresolved policy. + /// + public string? Inherits { get; init; } + + public string[] Rules { get; init; } = []; + + /// + /// Refs a stable (non-prerelease) version may be released from (REL003). Entries may use + /// a trailing *, for example refs/heads/release/*. + /// + public string[] StableRefs { get; init; } = []; + + /// + /// Refs a version with a non-zero PATCH component may be released from (REL004). + /// + public string[] ServicingRefs { get; init; } = []; + + /// + /// Refs that represent the trunk, where a release is not cut. Recorded so a policy can describe + /// the branch model it belongs to; no rule reads it directly today. + /// + public string[] TrunkRefs { get; init; } = []; + + /// + /// Refs a four-part version may be released from (REL006). + /// + public string[] FourPartRefs { get; init; } = []; + + /// + /// Whether four-part versions are permitted at all (REL006). + /// + public bool AllowFourPart { get; init; } +} + +public sealed record EligibilitySettings +{ + public const string SectionName = "Eligibility"; + + /// + /// Name of the selected policy. Defaults to the policy that reproduces the pipeline's + /// pre-eligibility behaviour, so a repository with no Release:Eligibility section is + /// unaffected. + /// + public string Policy { get; init; } = "ReleaseOnMain"; + + /// + /// Policies by name. Merged over the built-in policies, so a repository can select + /// TrunkReservesMinor without redeclaring it, and can override a built-in by name. + /// + public Dictionary Policies { get; init; } = []; + + /// + /// The settings a repository that configures nothing gets. + /// + public static EligibilitySettings Default { get; } = new(); +} diff --git a/src/src/Build/Settings/ReleaseChannelSettings.cs b/src/src/Build/Settings/ReleaseChannelSettings.cs new file mode 100644 index 0000000..4eb24a9 --- /dev/null +++ b/src/src/Build/Settings/ReleaseChannelSettings.cs @@ -0,0 +1,45 @@ +namespace Purview.Build.Settings; + +/// +/// A named release channel: where packages go, whether a GitHub release is cut, and which +/// prerelease labels are allowed. +/// +/// +/// A channel exists so a preview line can publish to a different feed with +/// GitHubRelease: false — dispatch-from-any-branch preview packages without cutting a GitHub +/// release. Every member is nullable so "not configured for this channel" is distinguishable from +/// "configured to false", and the top-level Release setting is used as the fallback. +/// +public sealed record ReleaseChannelSettings +{ + public const string StableChannelName = "stable"; + + /// + /// Feed this channel publishes to. Falls back to NuGet:FeedUrl. + /// + public string? FeedUrl { get; init; } + + /// + /// Whether a GitHub release is created for this channel. Falls back to the resolved + /// Release:GitHubRelease. + /// + public bool? GitHubRelease { get; init; } + + /// + /// Whether a prerelease version is marked as a GitHub prerelease. Falls back to + /// Release:MarkPrerelease. + /// + public bool? MarkPrerelease { get; init; } + + /// + /// Pattern the prerelease label must match (REL007), for example prerelease or + /// preview. A trailing * is a prefix match. Null disables the check even when + /// REL007 is enabled. + /// + public string? LabelPattern { get; init; } + + /// + /// The channel a repository that configures nothing releases on. + /// + public static ReleaseChannelSettings Stable { get; } = new(); +} diff --git a/src/src/Build/Settings/ReleaseContextSettings.cs b/src/src/Build/Settings/ReleaseContextSettings.cs new file mode 100644 index 0000000..41422f2 --- /dev/null +++ b/src/src/Build/Settings/ReleaseContextSettings.cs @@ -0,0 +1,40 @@ +namespace Purview.Build.Settings; + +/// +/// Simulated release context, so eligibility can be evaluated on a developer machine without being +/// on the branch or having the tags and feed state the rules judge against. +/// +/// +/// When any member is set, evaluation performs no network calls and every report states that +/// simulated context was used — a simulated verdict must never be mistakable for a real one. A +/// simulated context can therefore never drive a real publish; see +/// ReleaseSettings.ValidateSimulationInterlock. +/// +public sealed record ReleaseContextSettings +{ + public const string SectionName = "Context"; + + /// + /// Overrides the evaluated ref. Defaults to GITHUB_REF, then the current git branch. + /// + public string? Ref { get; init; } + + /// + /// Path to a newline- or JSON-delimited tag list, used instead of querying git. + /// + public string? ExistingTags { get; init; } + + /// + /// Path to a newline- or JSON-delimited version list treated as already on the feed, used + /// instead of querying it. + /// + public string? PublishedVersions { get; init; } + + /// + /// True when any simulated value is present. + /// + public bool IsSimulated => + !string.IsNullOrWhiteSpace(Ref) + || !string.IsNullOrWhiteSpace(ExistingTags) + || !string.IsNullOrWhiteSpace(PublishedVersions); +} diff --git a/src/src/Build/Settings/ReleaseSettings.cs b/src/src/Build/Settings/ReleaseSettings.cs index d0aef29..2b2097c 100644 --- a/src/src/Build/Settings/ReleaseSettings.cs +++ b/src/src/Build/Settings/ReleaseSettings.cs @@ -6,8 +6,37 @@ public sealed record ReleaseSettings { public const string SectionName = "Release"; + /// + /// Preset that derives and . Retained as the + /// primary switch; an explicitly set / wins over + /// whatever the preset implies. + /// public ReleaseMode Mode { get; set; } = ReleaseMode.None; + /// + /// Whether packages are pushed. Null means "derive from ". + /// + public bool? Publish { get; init; } + + /// + /// Whether the tag and GitHub release are created. Null means "derive from the channel, then + /// from ". + /// + public bool? GitHubRelease { get; init; } + + /// + /// Named channel, selecting the feed and the label policy. + /// + public string Channel { get; init; } = ReleaseChannelSettings.StableChannelName; + + public Dictionary Channels { get; init; } = []; + + /// + /// Runs the full pipeline but skips publishing and the GitHub release, logging what each would + /// have done. + /// + public bool DryRun { get; init; } + /// /// When true, the GitHub release module uploads every file in Build:ArtifactsFolder /// (for example .nupkg/.snupkg or .vsix) as release assets. @@ -21,6 +50,30 @@ public sealed record ReleaseSettings /// public bool MarkPrerelease { get; init; } = true; + public EligibilitySettings Eligibility { get; init; } = new(); + + public ReleaseContextSettings Context { get; init; } = new(); + + /// + /// The resolved settings for , or the stable defaults when the channel is + /// not declared. + /// + public ReleaseChannelSettings ResolveChannel() => + Channels.TryGetValue(Channel, out var channel) ? channel : ReleaseChannelSettings.Stable; + + /// + /// Whether packages should be pushed to a remote feed. + /// + public bool ShouldPublish() => Publish ?? (Mode == ReleaseMode.NuGet); + + /// + /// Whether the tag and GitHub release should be created. + /// + public bool ShouldCreateGitHubRelease() => + GitHubRelease + ?? ResolveChannel().GitHubRelease + ?? (Mode is ReleaseMode.NuGet or ReleaseMode.GitHubRelease); + /// /// Returns whether a release for should be created as a GitHub /// prerelease. @@ -29,6 +82,32 @@ public bool ShouldMarkPrerelease(NuGetVersion version) { ArgumentNullException.ThrowIfNull(version); - return MarkPrerelease && version.IsPrerelease; + var markPrerelease = ResolveChannel().MarkPrerelease ?? MarkPrerelease; + + return markPrerelease && version.IsPrerelease; + } + + /// + /// Fails when a simulated release context would drive a real publish. + /// + /// + /// Simulated tags and feed state exist to answer "what would happen"; letting them select which + /// versions are already released while actually pushing packages and cutting tags is the one + /// combination that can do real damage from a developer machine. + /// + public void ValidateSimulationInterlock() + { + if (!Context.IsSimulated) + return; + + if (Mode != ReleaseMode.NuGet && !ShouldPublish()) + return; + + throw new InvalidOperationException( + "Release:Context:* is set (simulated release context) while the release would really publish " + + $"(Release:Mode={Mode}, Release:Publish={ShouldPublish()}). Simulated context must never " + + "drive a real publish. Drop the Release:Context:* keys, or use 'release-explain' / " + + "Release:DryRun=true to evaluate without publishing." + ); } } diff --git a/src/src/Build/Settings/VersionSettings.cs b/src/src/Build/Settings/VersionSettings.cs new file mode 100644 index 0000000..ab578bf --- /dev/null +++ b/src/src/Build/Settings/VersionSettings.cs @@ -0,0 +1,44 @@ +namespace Purview.Build.Settings; + +/// +/// Where the release version comes from. +/// +public enum VersionSource +{ + /// + /// The version field of the repository root package.json. + /// + PackageJson, +} + +/// +/// How strictly a version string is validated. +/// +public enum VersionStrictness +{ + /// + /// parsing, which additionally accepts "legacy" + /// four-part versions such as 13.5.3.10, and one- and two-part versions such as 1.0. + /// + /// + /// The default, because it is exactly what the pipeline has always accepted. Two consuming + /// repositories ship four-part versions (aspirec4, build-sdk), so defaulting to + /// would stop them releasing. + /// + NuGet, + + /// + /// Strict SemVer 2.0 parsing: three numeric components with optional prerelease and build + /// metadata. Rejects four-part versions. + /// + SemVer2, +} + +public sealed record VersionSettings +{ + public const string SectionName = "Version"; + + public VersionSource Source { get; init; } = VersionSource.PackageJson; + + public VersionStrictness Strictness { get; init; } = VersionStrictness.NuGet; +} diff --git a/src/src/Build/Version/IVersionProvider.cs b/src/src/Build/Version/IVersionProvider.cs new file mode 100644 index 0000000..4af583c --- /dev/null +++ b/src/src/Build/Version/IVersionProvider.cs @@ -0,0 +1,36 @@ +using Purview.Build.Release; + +namespace Purview.Build.Version; + +/// +/// Produces the release units for a run. +/// +/// +/// An abstraction so Tag, MinVer and External sources can be added later without +/// touching PackModule or CreateGitHubReleaseModule: they read the resolved +/// and never recompute a version. +/// +interface IVersionProvider +{ + VersionSource Source { get; } + + /// + /// Resolves the raw version text and the units it produces. + /// + Task ResolveAsync( + VersionProviderContext context, + CancellationToken cancellationToken + ); +} + +/// +/// What a version provider is given. +/// +/// The repository root; relative paths resolve against it. +/// The channel the resolved units are released on. +/// How strictly the resolved version text is validated. +sealed record VersionProviderContext( + string RepositoryRoot, + string ChannelName, + VersionStrictness Strictness +); diff --git a/src/src/Build/Version/PackageJsonVersionProvider.cs b/src/src/Build/Version/PackageJsonVersionProvider.cs new file mode 100644 index 0000000..f50c6fc --- /dev/null +++ b/src/src/Build/Version/PackageJsonVersionProvider.cs @@ -0,0 +1,73 @@ +using Purview.Build.Release; +using System.Text.Json; + +namespace Purview.Build.Version; + +/// +/// Reads the version field from the repository root package.json. +/// +/// +/// The default and, today, the only source. Behaviour is deliberately unchanged from the original +/// VersionModule: the same file location, the same failure messages, and the same +/// NuGetVersion parsing — which accepts four-part versions, as two consuming repositories +/// require. +/// +sealed class PackageJsonVersionProvider : IVersionProvider +{ + public VersionSource Source => VersionSource.PackageJson; + + public async Task ResolveAsync( + VersionProviderContext context, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(context); + + var rawVersion = await ReadRawVersionAsync(context.RepositoryRoot, cancellationToken); + + if (!ReleaseUnitFactory.SatisfiesStrictness(rawVersion, context.Strictness)) + { + throw new InvalidOperationException( + context.Strictness == VersionStrictness.SemVer2 + ? $"The version '{rawVersion}' in package.json is not valid SemVer 2.0 " + + "(Version:Strictness=SemVer2). Use a three-part version, or set " + + "Version:Strictness=NuGet to allow four-part versions." + : $"The version '{rawVersion}' in package.json is not a valid SemVer." + ); + } + + var unit = + ReleaseUnitFactory.TryCreate(rawVersion, context.ChannelName, nameof(VersionSource.PackageJson)) + ?? throw new InvalidOperationException( + $"The version '{rawVersion}' in package.json is not a valid SemVer." + ); + + return new ReleaseUnitSet(rawVersion, [unit], nameof(VersionSource.PackageJson)); + } + + /// + /// Reads the raw version text, without validating it, so eligibility can report on a + /// version the pipeline would reject. + /// + public static async Task ReadRawVersionAsync( + string repositoryRoot, + CancellationToken cancellationToken + ) + { + var packageJsonPath = Path.Combine(repositoryRoot, "package.json"); + + if (!File.Exists(packageJsonPath)) + throw new FileNotFoundException($"Could not find package.json at {packageJsonPath}"); + + var packageJson = await File.ReadAllTextAsync(packageJsonPath, cancellationToken); + + using var document = JsonDocument.Parse(packageJson); + var version = document.RootElement.GetProperty("version").GetString(); + + return string.IsNullOrWhiteSpace(version) + ? throw new InvalidOperationException( + "The version field in package.json is missing or empty." + ) + : version; + } +} diff --git a/src/src/Build/Version/VersionProviders.cs b/src/src/Build/Version/VersionProviders.cs new file mode 100644 index 0000000..2bccf85 --- /dev/null +++ b/src/src/Build/Version/VersionProviders.cs @@ -0,0 +1,21 @@ +namespace Purview.Build.Version; + +/// +/// Selects the provider for a configured . +/// +/// +/// Adding a source means adding one provider and one entry here. Tag, MinVer and +/// External are deliberately not implemented yet; the abstraction exists so they can be, +/// without touching the modules that consume the resolved units. +/// +static class VersionProviders +{ + static readonly IVersionProvider[] All = [new PackageJsonVersionProvider()]; + + public static IVersionProvider For(VersionSource source) => + All.FirstOrDefault(provider => provider.Source == source) + ?? throw new InvalidOperationException( + $"Version:Source={source} is not implemented. Supported sources: " + + $"{string.Join(", ", All.Select(provider => provider.Source))}." + ); +} diff --git a/src/src/Build/appsettings.json b/src/src/Build/appsettings.json index 1e38300..d28d3ac 100644 --- a/src/src/Build/appsettings.json +++ b/src/src/Build/appsettings.json @@ -27,6 +27,7 @@ "RequireSymbolFiles": true, "RequireSourceLink": false, "RequireDeterministic": false, + "RequireExplicitContent": true, "RequiredCompilerFlags": [], "RequiredContent": {}, "ForbiddenContent": {} @@ -45,9 +46,20 @@ "AccessToken": null, "ProductHeader": "Purview.Build.Pipeline" }, + "Version": { + "Source": "PackageJson", + "Strictness": "NuGet" + }, "Release": { "Mode": "None", "UploadArtifacts": false, - "MarkPrerelease": true + "MarkPrerelease": true, + "Channel": "stable", + "DryRun": false, + "Channels": {}, + "Eligibility": { + "Policy": "ReleaseOnMain", + "Policies": {} + } } -} \ No newline at end of file +} diff --git a/src/tests/Build.IntegrationTests/ConcurrentReleaseLineTests.cs b/src/tests/Build.IntegrationTests/ConcurrentReleaseLineTests.cs new file mode 100644 index 0000000..6f7d1cd --- /dev/null +++ b/src/tests/Build.IntegrationTests/ConcurrentReleaseLineTests.cs @@ -0,0 +1,133 @@ +using NuGet.Versioning; +using Purview.Build.Infra; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// The concurrent-line scenario Model C exists for: a serviced stable line and an in-flight +/// prerelease line releasing independently, neither affecting the other's verdict. +/// +[NotInParallel] +public class ConcurrentReleaseLineTests +{ + // Both lines see the same tag history; only the ref and the version differ. + static readonly string[] SharedTags = ["v2.0.0", "v2.0.1", "v2.1.0-prerelease.1"]; + + static EligibilityDecision Evaluate(GitFixture fixture, string gitRef, string version) + { + var tagsPath = fixture.WriteList("tags.txt", SharedTags); + + ReleaseContextSettings contextSettings = new() { Ref = gitRef, ExistingTags = tagsPath }; + var context = fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(contextSettings, fixture.Root)); + + var policy = EligibilityPolicies.Resolve( + EligibilitySettings.Default, + EligibilityPolicies.TrunkReservesMinorName + ); + + var input = EligibilityInput.Create( + version, + VersionStrictness.NuGet, + context, + ReleaseChannelSettings.StableChannelName, + ReleaseChannelSettings.Stable, + nameof(VersionSource.PackageJson) + ); + + return EligibilityEvaluator.Evaluate(input, policy); + } + + [Test] + public async Task Evaluate_GivenServicedStableLine_IsEligible() + { + // Arrange + using var fixture = GitFixture.Create("2.0.2", branch: "release/2.0", tags: SharedTags); + + // Act + var decision = Evaluate(fixture, "refs/heads/release/2.0", "2.0.2"); + + // Assert + await Assert + .That(decision.Verdict) + .IsEqualTo(EligibilityVerdict.Release) + .Because(string.Join(Environment.NewLine, decision.Results.Select(result => result.Message))); + } + + [Test] + public async Task Evaluate_GivenInFlightPrereleaseLine_IsEligible() + { + // Arrange + using var fixture = GitFixture.Create("2.1.0-prerelease.2", branch: "release/2.1", tags: SharedTags); + + // Act + var decision = Evaluate(fixture, "refs/heads/release/2.1", "2.1.0-prerelease.2"); + + // Assert + await Assert + .That(decision.Verdict) + .IsEqualTo(EligibilityVerdict.Release) + .Because(string.Join(Environment.NewLine, decision.Results.Select(result => result.Message))); + } + + [Test] + public async Task Evaluate_GivenBothLines_NeitherAffectsTheOthersVerdict() + { + // Arrange + // REL005 is scoped to the line, so the 2.1 prerelease ahead of 2.0.2 must not block the + // 2.0 patch, and the serviced 2.0 line must not block 2.1 advancing. + using var fixture = GitFixture.Create("2.0.2", branch: "release/2.0", tags: SharedTags); + + // Act + var stable = Evaluate(fixture, "refs/heads/release/2.0", "2.0.2"); + var prerelease = Evaluate(fixture, "refs/heads/release/2.1", "2.1.0-prerelease.2"); + + // Assert + await Assert.That(stable.Verdict).IsEqualTo(EligibilityVerdict.Release); + await Assert.That(prerelease.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + public async Task ReleaseUnits_GivenBothLines_CarryDistinctLinesAndTags() + { + // Arrange + using var fixture = GitFixture.Create("2.0.2", branch: "release/2.0", tags: SharedTags); + + // Act + var stable = ReleaseUnitFactory.TryCreate( + "2.0.2", + ReleaseChannelSettings.StableChannelName, + nameof(VersionSource.PackageJson) + )!; + var prerelease = ReleaseUnitFactory.TryCreate( + "2.1.0-prerelease.2", + ReleaseChannelSettings.StableChannelName, + nameof(VersionSource.PackageJson) + )!; + + // Assert + await Assert.That(stable.Line).IsEqualTo("2.0"); + await Assert.That(prerelease.Line).IsEqualTo("2.1"); + await Assert.That(stable.Tag).IsEqualTo("v2.0.2"); + await Assert.That(prerelease.Tag).IsEqualTo("v2.1.0-prerelease.2"); + await Assert.That(fixture.Root).IsNotEmpty(); + } + + [Test] + public async Task ShouldMarkPrerelease_GivenBothLines_MarksOnlyThePrerelease() + { + // Arrange + // The prerelease must not be presented as the latest stable release on GitHub, while the + // serviced stable release must be. + ReleaseSettings settings = new() { Mode = ReleaseMode.NuGet }; + + // Act + var stableMarked = settings.ShouldMarkPrerelease(NuGetVersion.Parse("2.0.2")); + var prereleaseMarked = settings.ShouldMarkPrerelease(NuGetVersion.Parse("2.1.0-prerelease.2")); + + // Assert + await Assert.That(stableMarked).IsFalse(); + await Assert.That(prereleaseMarked).IsTrue(); + } +} diff --git a/src/tests/Build.IntegrationTests/ConsumerCompatibilityTests.cs b/src/tests/Build.IntegrationTests/ConsumerCompatibilityTests.cs new file mode 100644 index 0000000..27c1b96 --- /dev/null +++ b/src/tests/Build.IntegrationTests/ConsumerCompatibilityTests.cs @@ -0,0 +1,320 @@ +using Microsoft.Extensions.Configuration; +using Purview.Build.Configuration; +using Purview.Build.Infra; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// A fixture per consuming-repository shape, each asserting the pre-change release decision is +/// reproduced exactly, with the configuration file at the repository root as it is today. +/// +/// +/// The shapes are taken from the real repositories surveyed before this change: Model A on +/// main with a stable version (aspire-resourcekit, telemetry-sourcegenerator), Model A with a +/// prerelease version (sourcegenerator-framework, value-objects, zodsharp), Model A with a four-part +/// version (aspirec4, build-sdk), the documented Model B release-branch shape, and a +/// ProjectType=Web repository. +/// +/// The Web shape is reconstructed from the documented configuration rather than from the real +/// repository, which was not available locally when this was written. +/// +[NotInParallel] +public class ConsumerCompatibilityTests +{ + /// + /// Every consuming repository's configuration sets Release:Mode to None and relies on the + /// workflow's Release__Mode environment variable to outrank it. + /// + const string ModelAConfig = /*lang=json,strict*/ + """ + { + "Build": { + "Solution": "src/Product.slnx", + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" + }, + "Release": { "Mode": "None" } + } + """; + + const string WebConfig = /*lang=json,strict*/ + """ + { + "Build": { + "ProjectType": "Web", + "WebBuildOutput": "src/dist" + }, + "Release": { "Mode": "None" } + } + """; + + public static IEnumerable> Shapes() => + new ConsumerShape[] + { + new("aspire-resourcekit (Model A, stable)", "1.0.0", "main", ModelAConfig), + new("telemetry-sourcegenerator (Model A, stable)", "5.0.0", "main", ModelAConfig), + new("sourcegenerator-framework (Model A, prerelease)", "1.0.0-prerelease.54", "main", ModelAConfig), + new("value-objects (Model A, prerelease)", "1.0.0-prerelease.12", "main", ModelAConfig), + new("zodsharp (Model A, prerelease)", "2.0.1-prerelease.1", "main", ModelAConfig), + new("aspirec4 (Model A, four-part)", "13.5.3.10", "main", ModelAConfig), + new("build-sdk (Model A, four-part)", "1.0.2.2", "main", ModelAConfig), + new("documented Model B (release branch)", "2.0.0", "release", ModelAConfig), + new("purview.dev portal shape (Web)", "1.4.0", "main", WebConfig), + }.Select>(shape => () => shape); + + [Test] + [MethodDataSource(nameof(Shapes))] + public async Task Evaluate_GivenConsumerShapeWithUntaggedVersion_Releases(ConsumerShape shape) + { + ArgumentNullException.ThrowIfNull(shape); + + // Arrange + using var fixture = GitFixture.Create(shape.Version, shape.Branch, tags: ["v0.0.1"], configJson: shape.Config); + + // Act + var decision = Evaluate(fixture); + + // Assert + // Before this change the workflow released whenever v{version} did not exist. Every shape + // must still reach that outcome with no configuration added. + await Assert + .That(decision.Verdict) + .IsEqualTo(EligibilityVerdict.Release) + .Because($"{shape.Name}: {Describe(decision)}"); + } + + [Test] + [MethodDataSource(nameof(Shapes))] + public async Task Evaluate_GivenConsumerShapeWithTaggedVersion_SkipsWithExitCodeZero(ConsumerShape shape) + { + ArgumentNullException.ThrowIfNull(shape); + + // Arrange + using var fixture = GitFixture.Create( + shape.Version, + shape.Branch, + tags: [$"v{shape.Version}"], + configJson: shape.Config + ); + + // Act + var decision = Evaluate(fixture); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Skip); + await Assert.That(decision.ExitCode).IsEqualTo(0); + await Assert.That(decision.RuleId).IsEqualTo("REL002"); + } + + [Test] + [MethodDataSource(nameof(Shapes))] + public async Task Resolve_GivenConsumerShape_FindsTheRootConfigAtProbePositionOne(ConsumerShape shape) + { + ArgumentNullException.ThrowIfNull(shape); + + // Arrange + // A repository with purview-build.json at its root must resolve exactly as it does today: + // probe position 1, no shadow warnings, no user config. + using var fixture = GitFixture.Create(shape.Version, shape.Branch, configJson: shape.Config); + + // Act + var resolution = ConfigFileLocator.Resolve(fixture.Root); + + // Assert + await Assert + .That(resolution.Path) + .IsEqualTo(Path.GetFullPath(Path.Combine(fixture.Root, "purview-build.json"))); + await Assert.That(resolution.Source).IsEqualTo(ConfigSource.Probe); + await Assert.That(resolution.ShadowedPaths).IsEmpty(); + await Assert.That(resolution.UserConfigActive).IsFalse(); + await Assert.That(ConfigurationChain.DescribeWarnings(resolution)).IsEmpty(); + } + + [Test] + [MethodDataSource(nameof(Shapes))] + public async Task Publication_GivenConsumerShapeAndWorkflowReleaseMode_PublishesAndReleases(ConsumerShape shape) + { + ArgumentNullException.ThrowIfNull(shape); + + // Arrange + // The caller workflow sets Release__Mode=NuGet, which outranks the repository's + // "Mode": "None". The derived switches must match the pre-change skip conditions. + using var fixture = GitFixture.Create(shape.Version, shape.Branch, configJson: shape.Config); + + var configuration = BuildConfiguration(fixture, ("Release__Mode", "NuGet")); + var release = configuration.GetSection(ReleaseSettings.SectionName).Get()!; + + // Act + var publish = release.ShouldPublish(); + var githubRelease = release.ShouldCreateGitHubRelease(); + + // Assert + await Assert.That(publish).IsTrue().Because($"{shape.Name} publishes to NuGet today."); + await Assert.That(githubRelease).IsTrue().Because($"{shape.Name} cuts a GitHub release today."); + await Assert.That(release.DryRun).IsFalse(); + } + + [Test] + [MethodDataSource(nameof(Shapes))] + public async Task Configuration_GivenConsumerShape_PreservesEveryConfiguredValue(ConsumerShape shape) + { + ArgumentNullException.ThrowIfNull(shape); + + // Arrange + using var fixture = GitFixture.Create(shape.Version, shape.Branch, configJson: shape.Config); + + var configuration = BuildConfiguration(fixture); + + // Act + var build = configuration.GetSection(BuildSettings.SectionName).Get()!; + var version = configuration.GetSection(VersionSettings.SectionName).Get()!; + + // Assert + // The new Version section must default to today's behaviour, and nothing in the new + // configuration surface may disturb what the repository already declares. + await Assert.That(version.Source).IsEqualTo(VersionSource.PackageJson); + await Assert + .That(version.Strictness) + .IsEqualTo(VersionStrictness.NuGet) + .Because("four-part versions must keep parsing, as aspirec4 and build-sdk require."); + + if (shape.Config == WebConfig) + { + await Assert.That(build.ProjectType).IsEqualTo(ProjectType.Web); + await Assert.That(build.WebBuildOutput).IsEqualTo("src/dist"); + } + else + { + await Assert.That(build.Solution).IsEqualTo("src/Product.slnx"); + await Assert.That(build.TestFilter).IsEqualTo("/*/*/*/*[Category=Unit]"); + } + } + + [Test] + public async Task Resolve_GivenConfigMovedToDotConfig_AnchorsPathsIdentically() + { + // Arrange + // Moving the configuration file must require no other edit: every relative path in it still + // resolves against the repository root. + using var atRoot = GitFixture.Create("1.0.0", configJson: ModelAConfig); + using var inDotConfig = GitFixture.Create( + "1.0.0", + configJson: ModelAConfig, + configRelativePath: ".config/purview-build.json" + ); + + // Act + var rootSolution = ResolveSolutionPath(atRoot); + var movedSolution = ResolveSolutionPath(inDotConfig); + + // Assert + await Assert + .That(Path.GetRelativePath(atRoot.Root, rootSolution)) + .IsEqualTo(Path.GetRelativePath(inDotConfig.Root, movedSolution)) + .Because("Build:Solution anchors to the repository root, not the config file's directory."); + } + + static string ResolveSolutionPath(GitFixture fixture) + { + var configuration = BuildConfiguration(fixture); + var build = configuration.GetSection(BuildSettings.SectionName).Get()!; + + return Path.GetFullPath(Path.Combine(fixture.Root, build.Solution)); + } + + static IConfigurationRoot BuildConfiguration(GitFixture fixture, params (string Key, string Value)[] environment) + { + var resolution = ConfigFileLocator.Resolve(fixture.Root); + + PipelineStartup startup = new(AppSettingsDirectory.Path, fixture.Root, resolution); + + ConfigurationBuilder builder = new(); + builder.AddJsonFile(Path.Combine(startup.PipelineDirectory, "appsettings.json"), optional: false); + + if (resolution.Path is not null) + builder.AddJsonFile(resolution.Path, optional: true); + + // The reusable workflow's environment layer, which outranks the repository's JSON. + if (environment.Length > 0) + { + builder.AddInMemoryCollection( + environment.Select(entry => new KeyValuePair( + entry.Key.Replace("__", ":", StringComparison.Ordinal), + entry.Value + )) + ); + } + + return builder.Build(); + } + + static string Describe(EligibilityDecision decision) => + string.Join(Environment.NewLine, decision.Results.Select(result => result.Message)); + + static EligibilityDecision Evaluate(GitFixture fixture) + { + var configuration = BuildConfiguration(fixture); + var release = configuration.GetSection(ReleaseSettings.SectionName).Get() ?? new(); + var version = configuration.GetSection(VersionSettings.SectionName).Get() ?? new(); + + var context = fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(release.Context, fixture.Root)); + + var policy = EligibilityPolicies.Resolve(release.Eligibility, release.Eligibility.Policy); + + var rawVersion = fixture.InWorkingDirectory(() => + System + .Text.Json.JsonDocument.Parse(File.ReadAllText(Path.Combine(fixture.Root, "package.json"))) + .RootElement.GetProperty("version") + .GetString()! + ); + + var input = EligibilityInput.Create( + rawVersion, + version.Strictness, + context, + release.Channel, + release.ResolveChannel(), + version.Source.ToString() + ); + + return EligibilityEvaluator.Evaluate(input, policy); + } +} + +/// +/// One consuming-repository shape: the version it ships, the branch it releases from, and the +/// configuration file it has at its root. +/// +public sealed record ConsumerShape(string Name, string Version, string Branch, string Config) +{ + public override string ToString() => Name; +} + +/// +/// Locates the shipped appsettings.json directory. +/// +static class AppSettingsDirectory +{ + public static string Path { get; } = Find(); + + static string Find() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + var candidate = System.IO.Path.Combine(directory.FullName, "src", "src", "Build"); + if (File.Exists(System.IO.Path.Combine(candidate, "appsettings.json"))) + return candidate; + } + + throw new InvalidOperationException( + $"Could not locate src/src/Build/appsettings.json from '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/src/tests/Build.IntegrationTests/GitContextTests.cs b/src/tests/Build.IntegrationTests/GitContextTests.cs new file mode 100644 index 0000000..fbd6a52 --- /dev/null +++ b/src/tests/Build.IntegrationTests/GitContextTests.cs @@ -0,0 +1,195 @@ +using Purview.Build.Helpers; +using Purview.Build.Infra; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// Repository-root resolution and ref/tag discovery against a real .git. +/// +[NotInParallel] +public class GitContextTests +{ + static readonly string[] SimulatedTagList = ["v9.9.9"]; + + static readonly string[] JsonTagList = ["v1.0.0", "v1.1.0"]; + + [Test] + public async Task FindRepositoryRoot_GivenARealRepository_ResolvesToThePackageJsonDirectory() + { + // Arrange + using var fixture = GitFixture.Create("1.0.0"); + var nested = Path.Combine(fixture.Root, "src", "deep", "nested"); + Directory.CreateDirectory(nested); + + // Act + var root = PathHelpers.FindRepositoryRoot(nested); + + // Assert + await Assert + .That(Path.GetFullPath(root)) + .IsEqualTo(Path.GetFullPath(fixture.Root)) + .Because("the root is the nearest ancestor containing package.json."); + } + + [Test] + public async Task FindRepositoryRoot_GivenNoPackageJson_FailsWithActionableGuidance() + { + // Arrange + var directory = Path.Combine(Path.GetTempPath(), "purview-build-norepo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + // Act + string Act() => PathHelpers.FindRepositoryRoot(directory); + + // Assert + var exception = await Assert.That((Func)Act).Throws(); + await Assert.That(exception!.Message).Contains("package.json"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task Resolve_GivenARealRepository_DiscoversTheCurrentBranchAsARef() + { + // Arrange + using var fixture = GitFixture.Create("1.0.0", branch: "release/2.0"); + + // Act + var context = fixture.InWorkingDirectory(() => + ReleaseContextProvider.Resolve(new ReleaseContextSettings(), fixture.Root) + ); + + // Assert + await Assert.That(context.Ref).IsEqualTo("refs/heads/release/2.0"); + await Assert.That(context.RefSource).Contains("git branch"); + await Assert.That(context.Simulated).IsFalse(); + } + + [Test] + public async Task Resolve_GivenARealRepository_DiscoversEveryExistingTag() + { + // Arrange + using var fixture = GitFixture.Create("2.0.2", tags: ["v2.0.0", "v2.0.1", "v1.9.9"]); + + // Act + var context = fixture.InWorkingDirectory(() => + ReleaseContextProvider.Resolve(new ReleaseContextSettings(), fixture.Root) + ); + + // Assert + await Assert.That(context.ExistingTags).Contains("v2.0.0"); + await Assert.That(context.ExistingTags).Contains("v2.0.1"); + await Assert.That(context.ExistingTags).Contains("v1.9.9"); + await Assert.That(context.HasTag("v2.0.1")).IsTrue(); + await Assert.That(context.HasTag("v2.0.2")).IsFalse(); + } + + [Test] + public async Task Resolve_GivenGithubRefInTheEnvironment_PrefersItOverTheGitBranch() + { + // Arrange + // On a build agent the checked-out branch is not necessarily the triggering ref. + using var fixture = GitFixture.Create("1.0.0", branch: "main"); + var previous = Environment.GetEnvironmentVariable("GITHUB_REF"); + + try + { + Environment.SetEnvironmentVariable("GITHUB_REF", "refs/heads/release/3.1"); + + // Act + var context = fixture.InWorkingDirectory(() => + ReleaseContextProvider.Resolve(new ReleaseContextSettings(), fixture.Root) + ); + + // Assert + await Assert.That(context.Ref).IsEqualTo("refs/heads/release/3.1"); + await Assert.That(context.RefSource).IsEqualTo("GITHUB_REF"); + } + finally + { + Environment.SetEnvironmentVariable("GITHUB_REF", previous); + } + } + + [Test] + public async Task Resolve_GivenSimulatedContext_MakesNoGitOrNetworkLookups() + { + // Arrange + // A simulated evaluation must be inert: it must not pick up the developer's current branch + // or the repository's real tags, or a simulated verdict would silently blend with reality. + using var fixture = GitFixture.Create("2.0.2", branch: "main", tags: ["v2.0.0"]); + var tagsPath = fixture.WriteList("tags.txt", ["v9.9.9"]); + + ReleaseContextSettings settings = new() { Ref = "refs/heads/release/2.0", ExistingTags = tagsPath }; + + // Act + var context = fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(settings, fixture.Root)); + + // Assert + await Assert.That(context.Simulated).IsTrue(); + await Assert.That(context.Ref).IsEqualTo("refs/heads/release/2.0"); + await Assert.That(context.ExistingTags).IsEquivalentTo(SimulatedTagList); + await Assert + .That(context.ExistingTags) + .DoesNotContain("v2.0.0") + .Because("the real repository's tags must not leak into a simulated context."); + } + + [Test] + public async Task Resolve_GivenSimulatedRefOnly_DoesNotFallBackToTheRealTags() + { + // Arrange + using var fixture = GitFixture.Create("2.0.2", tags: ["v2.0.0", "v2.0.1"]); + ReleaseContextSettings settings = new() { Ref = "refs/heads/release/2.0" }; + + // Act + var context = fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(settings, fixture.Root)); + + // Assert + await Assert.That(context.Simulated).IsTrue(); + await Assert.That(context.ExistingTags).IsEmpty(); + await Assert.That(context.PublishedVersionsKnown).IsFalse(); + } + + [Test] + public async Task Resolve_GivenAMissingSimulatedTagFile_FailsNamingThePath() + { + // Arrange + using var fixture = GitFixture.Create("1.0.0"); + ReleaseContextSettings settings = new() { ExistingTags = "does-not-exist.txt" }; + + // Act + ReleaseContext Act() => + fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(settings, fixture.Root)); + + // Assert + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("Release:Context:ExistingTags"); + await Assert.That(exception!.Message).Contains("does-not-exist.txt"); + } + + [Test] + public async Task Resolve_GivenJsonTagList_ReadsItAsAnArray() + { + // Arrange + // The documented format is newline- or JSON-delimited; `gh api`-style output is JSON. + using var fixture = GitFixture.Create("1.0.0"); + var path = Path.Combine(fixture.Root, "tags.json"); + await File.WriteAllTextAsync(path, """["v1.0.0", "v1.1.0"]"""); + + ReleaseContextSettings settings = new() { ExistingTags = path }; + + // Act + var context = fixture.InWorkingDirectory(() => ReleaseContextProvider.Resolve(settings, fixture.Root)); + + // Assert + await Assert.That(context.ExistingTags).IsEquivalentTo(JsonTagList); + } +} diff --git a/src/tests/Build.IntegrationTests/Infra/GitFixture.cs b/src/tests/Build.IntegrationTests/Infra/GitFixture.cs new file mode 100644 index 0000000..cef4595 --- /dev/null +++ b/src/tests/Build.IntegrationTests/Infra/GitFixture.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; + +namespace Purview.Build.Infra; + +/// +/// A throwaway git repository in a temp directory: a root package.json with a chosen version, +/// a chosen branch name, and a chosen set of tags. +/// +/// +/// Repository-root resolution and ref/tag discovery are asserted against a real .git rather +/// than a mock, because what is being tested is precisely the interaction with git and the +/// filesystem. No network and no container: git init plus local commits only. +/// +sealed class GitFixture : IDisposable +{ + GitFixture(string root) + { + Root = root; + } + + public string Root { get; } + + /// + /// Creates a repository with in package.json, checked out on + /// , with already created. + /// + public static GitFixture Create( + string version, + string branch = "main", + IEnumerable? tags = null, + string? configJson = null, + string? configRelativePath = null + ) + { + var root = Path.Combine(Path.GetTempPath(), "purview-build-git-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + GitFixture fixture = new(root); + + try + { + File.WriteAllText( + Path.Combine(root, "package.json"), + $$"""{ "name": "fixture", "version": "{{version}}" }""" + ); + + if (configJson is not null) + { + var relative = configRelativePath ?? "purview-build.json"; + var path = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, configJson); + } + + fixture.Git("init", "--initial-branch", branch); + // A fixture must not depend on the developer's global git identity. + fixture.Git("config", "user.email", "fixture@purview.dev"); + fixture.Git("config", "user.name", "Purview Fixture"); + fixture.Git("config", "commit.gpgsign", "false"); + fixture.Git("add", "."); + fixture.Git("commit", "-m", "chore: fixture"); + + foreach (var tag in tags ?? []) + fixture.Git("tag", tag); + + return fixture; + } + catch + { + fixture.Dispose(); + throw; + } + } + + /// + /// Writes a newline-delimited list into the fixture and returns its absolute path, for the + /// Release:Context:* keys. + /// + public string WriteList(string fileName, IEnumerable entries) + { + var path = Path.Combine(Root, fileName); + File.WriteAllLines(path, entries); + + return path; + } + + public string Git(params string[] arguments) + { + ProcessStartInfo startInfo = new("git") + { + WorkingDirectory = Root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = + Process.Start(startInfo) ?? throw new InvalidOperationException("git could not be started."); + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + return process.ExitCode == 0 + ? output + : throw new InvalidOperationException( + $"git {string.Join(' ', arguments)} failed with exit code {process.ExitCode}: {error}" + ); + } + + /// + /// Runs with the process working directory inside the fixture. + /// + /// + /// The tool resolves the repository root from the current directory, so a test that exercises + /// that resolution has to actually change it. Restored afterwards. + /// + public T InWorkingDirectory(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + var previous = Environment.CurrentDirectory; + + try + { + Environment.CurrentDirectory = Root; + + return action(); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + + public void Dispose() + { + if (!Directory.Exists(Root)) + return; + + try + { + // git marks objects read-only, which blocks a plain recursive delete on Windows. + foreach (var file in Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + catch (UnauthorizedAccessException) + { + // As above. + } + } +} diff --git a/src/tests/Build.IntegrationTests/ArtifactsCleanupTests.cs b/src/tests/Build.UnitTests/ArtifactsCleanupTests.cs similarity index 100% rename from src/tests/Build.IntegrationTests/ArtifactsCleanupTests.cs rename to src/tests/Build.UnitTests/ArtifactsCleanupTests.cs diff --git a/src/tests/Build.UnitTests/Build.UnitTests.csproj b/src/tests/Build.UnitTests/Build.UnitTests.csproj new file mode 100644 index 0000000..bd23ee4 --- /dev/null +++ b/src/tests/Build.UnitTests/Build.UnitTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/tests/Build.UnitTests/ConfigResolutionScenarioTests.cs b/src/tests/Build.UnitTests/ConfigResolutionScenarioTests.cs new file mode 100644 index 0000000..b6e2c5b --- /dev/null +++ b/src/tests/Build.UnitTests/ConfigResolutionScenarioTests.cs @@ -0,0 +1,176 @@ +using Purview.Build.Configuration; +using Purview.Build.Infra; + +namespace Purview.Build; + +/// +/// Every case in src/tests/fixtures/config-resolution-scenarios.json, driven from the file, +/// against a real filesystem. +/// +/// +/// Marked because the scenarios set PURVIEW_BUILD_CONFIG +/// and the build-agent variables that decide locality, which are process-wide. +/// +[NotInParallel] +public class ConfigResolutionScenarioTests +{ + public static IEnumerable> AllScenarios() => + Scenarios.ConfigResolution.Scenarios.Select>(scenario => () => scenario); + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Resolve_GivenScenario_ResolvesTheExpectedFile(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + + // Assert + if (scenario.Expected.ExitCode != 0) + { + await Assert + .That(outcome.Error) + .IsNotNull() + .Because($"{scenario.Name}: expected a failure, got {outcome.Resolution?.Path}"); + + if (scenario.Expected.ErrorContains is not null) + await Assert.That(outcome.Error!.Message).Contains(scenario.Expected.ErrorContains); + + return; + } + + await Assert.That(outcome.Error).IsNull().Because($"{scenario.Name}: {outcome.Error?.Message}"); + + var expectedPath = scenario.Expected.ResolvedPath is null + ? null + : repository.FullPath(scenario.Expected.ResolvedPath); + + await Assert.That(outcome.Resolution!.Path).IsEqualTo(expectedPath).Because($"{scenario.Name}: {scenario.Why}"); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Resolve_GivenScenario_ReportsTheExpectedShadowedFiles(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + + // Assert + if (scenario.Expected.ExitCode != 0) + return; + + var expected = scenario + .Expected.ShadowWarnings.Select(repository.FullPath) + .Order(StringComparer.Ordinal) + .ToList(); + var actual = outcome.Resolution!.ShadowedPaths.Order(StringComparer.Ordinal).ToList(); + + await Assert + .That(actual) + .IsEquivalentTo(expected) + .Because($"{scenario.Name}: every shadowed file must be named, and nothing else."); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Resolve_GivenScenario_ReportsUserConfigActivationCorrectly(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + if (scenario.Expected.UserConfigActive is null) + return; + + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + + // Assert + await Assert + .That(outcome.Resolution!.UserConfigActive) + .IsEqualTo(scenario.Expected.UserConfigActive!.Value) + .Because($"{scenario.Name}: {scenario.Why}"); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Resolve_GivenScenario_WarnsAboutANearMissFilename(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + + // Assert + if (scenario.Expected.NearMissContains is null) + { + if (outcome.Resolution is not null) + await Assert.That(outcome.Resolution.NearMissPath).IsNull(); + + return; + } + + await Assert + .That(outcome.Resolution!.NearMissPath) + .IsNotNull() + .Because($"{scenario.Name}: a near-miss filename must be reported, not silently ignored."); + await Assert.That(outcome.Resolution!.NearMissPath!).Contains(scenario.Expected.NearMissContains); + } + + [Test] + public async Task Resolve_GivenNoConfig_ProducesNoWarnings() + { + // Arrange + // Absence of configuration is entirely valid and must not warn. + ConfigScenario scenario = new() { Name = "no-config", Files = [] }; + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + + // Assert + await Assert.That(outcome.Resolution!.Found).IsFalse(); + await Assert.That(ConfigurationChain.DescribeWarnings(outcome.Resolution!)).IsEmpty(); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Resolve_GivenScenario_AnchorsRelativePathsToTheRepositoryRoot(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + // Wherever the configuration file is found, a relative Build:Solution must resolve to the + // same absolute file. Anchoring to the config file's own directory would silently change + // the meaning of every existing path the moment a repository moved its config. + if (scenario.Expected.ExitCode != 0 || scenario.Expected.ResolvedPath is null) + return; + + using ScenarioRepository repository = new(scenario); + + // Act + var outcome = repository.Resolve(); + var solution = Path.GetFullPath( + Path.Combine(repository.Root, repository.ReadSolution(outcome.Resolution!.Path!)) + ); + + // Assert + await Assert + .That(solution) + .IsEqualTo(repository.FullPath("src/Product.slnx")) + .Because($"{scenario.Name}: relative paths anchor to the repository root."); + } +} diff --git a/src/tests/Build.UnitTests/EligibilityPolicyResolutionTests.cs b/src/tests/Build.UnitTests/EligibilityPolicyResolutionTests.cs new file mode 100644 index 0000000..089d3d7 --- /dev/null +++ b/src/tests/Build.UnitTests/EligibilityPolicyResolutionTests.cs @@ -0,0 +1,271 @@ +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +public class EligibilityPolicyResolutionTests +{ + static readonly string[] TrunkReservesMinorRules = ["REL001", "REL002", "REL003", "REL004", "REL005"]; + + static readonly string[] FourPartServicingRules = ["REL001", "REL002", "REL003", "REL004", "REL005", "REL006"]; + + static readonly string[] ParseAndTagRulesOnly = ["REL001", "REL002"]; + + static readonly string[] ParseRuleOnly = ["REL001"]; + + static readonly string[] NarrowedStableRefs = ["refs/heads/release/2.0"]; + + static readonly string[] InheritedServicingRefs = ["refs/heads/release/*"]; + + static readonly string[] OverriddenStableRefs = ["refs/heads/trunk"]; + + static ResolvedPolicy Resolve(string name, params (string Name, EligibilityPolicySettings Policy)[] policies) + { + EligibilitySettings settings = new() + { + Policy = name, + Policies = policies.ToDictionary(entry => entry.Name, entry => entry.Policy), + }; + + return EligibilityPolicies.Resolve(settings, name); + } + + [Test] + public async Task Resolve_GivenBuiltInTrunkReservesMinor_EnablesTheDocumentedRules() + { + // Arrange + // TrunkReservesMinor ships built in so adopting Model C is a policy name, not a config block. + + // Act + var policy = EligibilityPolicies.Resolve( + EligibilitySettings.Default, + EligibilityPolicies.TrunkReservesMinorName + ); + + // Assert + await Assert.That(policy.RuleIds).IsEquivalentTo(TrunkReservesMinorRules); + await Assert.That(policy.TrunkRefs).Contains("refs/heads/main"); + } + + [Test] + public async Task Resolve_GivenInheritsWithAdditiveDelta_AddsToTheParentRules() + { + // Arrange + // FourPartServicing is defined as "TrunkReservesMinor plus REL006". + + // Act + var policy = EligibilityPolicies.Resolve( + EligibilitySettings.Default, + EligibilityPolicies.FourPartServicingName + ); + + // Assert + await Assert.That(policy.RuleIds).IsEquivalentTo(FourPartServicingRules); + await Assert.That(policy.AllowFourPart).IsTrue(); + await Assert + .That(policy.InheritanceChain) + .IsEquivalentTo( + new[] { EligibilityPolicies.FourPartServicingName, EligibilityPolicies.TrunkReservesMinorName } + ); + } + + [Test] + public async Task Resolve_GivenInheritsWithSubtractiveDelta_RemovesTheParentRule() + { + // Arrange + EligibilityPolicySettings relaxed = new() + { + Inherits = EligibilityPolicies.TrunkReservesMinorName, + Rules = ["-REL005"], + }; + + // Act + var policy = Resolve("Relaxed", ("Relaxed", relaxed)); + + // Assert + await Assert.That(policy.Enables("REL005")).IsFalse(); + await Assert.That(policy.Enables("REL004")).IsTrue(); + } + + [Test] + public async Task Resolve_GivenInheritsWithNoRules_InheritsTheParentRulesUnchanged() + { + // Arrange + EligibilityPolicySettings renamed = new() { Inherits = EligibilityPolicies.TrunkReservesMinorName }; + + // Act + var policy = Resolve("Renamed", ("Renamed", renamed)); + + // Assert + await Assert.That(policy.RuleIds).IsEquivalentTo(TrunkReservesMinorRules); + } + + [Test] + public async Task Resolve_GivenAbsoluteRuleList_ReplacesTheParentRules() + { + // Arrange + EligibilityPolicySettings absolute = new() + { + Inherits = EligibilityPolicies.TrunkReservesMinorName, + Rules = ["REL001", "REL002"], + }; + + // Act + var policy = Resolve("Absolute", ("Absolute", absolute)); + + // Assert + await Assert.That(policy.RuleIds).IsEquivalentTo(ParseAndTagRulesOnly); + } + + [Test] + public async Task Resolve_GivenChildRefList_NarrowsRatherThanInherits() + { + // Arrange + // A non-empty child list replaces the parent's, so a policy can narrow a ref set. + EligibilityPolicySettings narrowed = new() + { + Inherits = EligibilityPolicies.TrunkReservesMinorName, + StableRefs = ["refs/heads/release/2.0"], + }; + + // Act + var policy = Resolve("Narrowed", ("Narrowed", narrowed)); + + // Assert + await Assert.That(policy.StableRefs).IsEquivalentTo(NarrowedStableRefs); + await Assert.That(policy.ServicingRefs).IsEquivalentTo(InheritedServicingRefs); + } + + [Test] + public async Task Resolve_GivenMissingParent_FailsNamingTheUnresolvedPolicy() + { + // Arrange + EligibilityPolicySettings orphan = new() { Inherits = "DoesNotExist", Rules = ["+REL006"] }; + + // Act + ResolvedPolicy Act() => Resolve("Orphan", ("Orphan", orphan)); + + // Assert + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).Contains("DoesNotExist"); + await Assert.That(exception!.Message).Contains("Orphan"); + } + + [Test] + public async Task Resolve_GivenUnknownPolicyName_FailsListingTheAvailablePolicies() + { + // Arrange + + // Act + static ResolvedPolicy act() => EligibilityPolicies.Resolve(EligibilitySettings.Default, "Nonsense"); + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains("Nonsense"); + await Assert.That(exception!.Message).Contains(EligibilityPolicies.ReleaseOnMainName); + } + + [Test] + public async Task Resolve_GivenCyclicInheritance_FailsRatherThanRecursing() + { + // Arrange + EligibilityPolicySettings left = new() { Inherits = "Right", Rules = ["+REL006"] }; + EligibilityPolicySettings right = new() { Inherits = "Left", Rules = ["+REL007"] }; + + // Act + ResolvedPolicy act() => Resolve("Left", ("Left", left), ("Right", right)); + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains("inherits from itself"); + } + + [Test] + public async Task Resolve_GivenMixedAbsoluteAndDeltaRules_FailsRatherThanGuessing() + { + // Arrange + // The intended precedence of a mixed list is not obvious, so it is rejected outright. + EligibilityPolicySettings mixed = new() + { + Inherits = EligibilityPolicies.TrunkReservesMinorName, + Rules = ["REL001", "+REL006"], + }; + + // Act + ResolvedPolicy act() => Resolve("Mixed", ("Mixed", mixed)); + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains("mixes absolute rule entries"); + } + + [Test] + public async Task Resolve_GivenUnknownRuleId_FailsListingTheKnownRules() + { + // Arrange + EligibilityPolicySettings bogus = new() { Rules = ["REL999"] }; + + // Act + ResolvedPolicy act() => Resolve("Bogus", ("Bogus", bogus)); + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains("REL999"); + await Assert.That(exception!.Message).Contains("REL001"); + } + + [Test] + public async Task Resolve_GivenARepositoryPolicyNamedLikeABuiltIn_PrefersTheRepositoryPolicy() + { + // Arrange + // A repository must be able to override a built-in policy by declaring the same name. + EligibilityPolicySettings overridden = new() { Rules = ["REL001"], StableRefs = ["refs/heads/trunk"] }; + + // Act + var policy = Resolve( + EligibilityPolicies.ReleaseOnMainName, + (EligibilityPolicies.ReleaseOnMainName, overridden) + ); + + // Assert + await Assert.That(policy.RuleIds).IsEquivalentTo(ParseRuleOnly); + await Assert.That(policy.StableRefs).IsEquivalentTo(OverriddenStableRefs); + } + + [Test] + public async Task Resolve_GivenEmptyPolicyName_FailsWithActionableGuidance() + { + // Arrange + EligibilitySettings settings = new() { Policy = "" }; + + // Act + ResolvedPolicy act() => EligibilityPolicies.Resolve(settings, settings.Policy); + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains(EligibilityPolicies.ReleaseOnMainName); + } + + [Test] + public async Task Resolve_GivenEveryBuiltInPolicy_OrdersRulesById() + { + // Arrange + // Evaluation order is rule-ID order, so resolution must sort rather than preserve + // declaration or delta-application order. + string[] names = + [ + EligibilityPolicies.ReleaseOnMainName, + EligibilityPolicies.TrunkReservesMinorName, + EligibilityPolicies.FourPartServicingName, + ]; + + // Act + var policies = names.Select(name => EligibilityPolicies.Resolve(EligibilitySettings.Default, name)); + + // Assert + foreach (var policy in policies) + { + await Assert.That(policy.RuleIds).IsEquivalentTo(policy.RuleIds.Order(StringComparer.Ordinal).ToList()); + } + } +} diff --git a/src/tests/Build.UnitTests/EligibilityScenarioTests.cs b/src/tests/Build.UnitTests/EligibilityScenarioTests.cs new file mode 100644 index 0000000..22eefab --- /dev/null +++ b/src/tests/Build.UnitTests/EligibilityScenarioTests.cs @@ -0,0 +1,159 @@ +using NuGet.Versioning; +using Purview.Build.Infra; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// Every case in src/tests/fixtures/eligibility-scenarios.json, driven from the file. +/// +/// +/// Adding a rule or policy means appending cases to that file; no test code changes. The same file +/// drives just release-matrix, which runs each case through the real CLI, so the in-process +/// evaluator and the shipped binary are held to one specification. +/// +public class EligibilityScenarioTests +{ + public static IEnumerable> AllScenarios() => + Scenarios.Eligibility.Scenarios.Select>(scenario => + () => scenario + ); + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Evaluate_GivenScenario_MatchesExpectedVerdict(EligibilityScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + var decision = Evaluate(scenario); + + // Act + var verdict = decision.Verdict.ToString(); + + // Assert + await Assert + .That(verdict) + .IsEqualTo(scenario.Expected.Verdict) + .Because($"{scenario.Name}: {scenario.Why}{Environment.NewLine}{Describe(decision)}"); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Evaluate_GivenScenario_MatchesExpectedDecidingRule(EligibilityScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + var decision = Evaluate(scenario); + + // Act + var ruleId = decision.RuleId; + + // Assert + await Assert + .That(ruleId) + .IsEqualTo(scenario.Expected.RuleId) + .Because($"{scenario.Name}: {scenario.Why}{Environment.NewLine}{Describe(decision)}"); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Evaluate_GivenScenario_MatchesExpectedExitCode(EligibilityScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + var decision = Evaluate(scenario); + + // Act + var exitCode = decision.ExitCode; + + // Assert + await Assert + .That(exitCode) + .IsEqualTo(scenario.Expected.ExitCode) + .Because($"{scenario.Name}: a skip must stay exit 0 and a policy violation exit 1."); + } + + [Test] + [MethodDataSource(nameof(AllScenarios))] + public async Task Evaluate_GivenScenario_ShortCircuitsAtTheDecidingRule(EligibilityScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + // Arrange + var decision = Evaluate(scenario); + + // Act + var lastEvaluated = decision.Results.Count > 0 ? decision.Results[^1].RuleId : null; + + // Assert + // A non-passing verdict must stop at the rule that decided it, so no rule after the + // short-circuit point is ever evaluated. + if (scenario.Expected.RuleId is null) + await Assert.That(decision.ShortCircuitRuleId).IsNull(); + else + await Assert.That(lastEvaluated).IsEqualTo(scenario.Expected.RuleId); + } + + [Test] + public async Task AllScenarios_NameEveryRuleIdThatCanDecide() + { + // Arrange + // Guards the fixture itself: a typo in an expected rule ID would otherwise silently assert + // against a rule that does not exist. + var known = EligibilityRules.KnownIds; + + // Act + var referenced = Scenarios + .Eligibility.Scenarios.Select(scenario => scenario.Expected.RuleId) + .Where(ruleId => ruleId is not null) + .Distinct() + .ToList(); + + // Assert + foreach (var ruleId in referenced) + await Assert.That(known).Contains(ruleId!); + } + + static EligibilityDecision Evaluate(EligibilityScenario scenario) + { + EligibilitySettings settings = new() { Policy = scenario.Policy, Policies = Scenarios.Eligibility.Policies }; + + var policy = EligibilityPolicies.Resolve(settings, scenario.Policy); + + List published = []; + foreach (var version in scenario.PublishedVersions ?? []) + { + if (NuGetVersion.TryParse(version, out var parsed)) + published.Add(parsed); + } + + ReleaseContext context = new( + Ref: scenario.Ref, + RefSource: "scenario fixture", + ExistingTags: scenario.ExistingTags, + PublishedVersions: published, + PublishedVersionsKnown: scenario.PublishedVersions is not null, + Simulated: true + ); + + var strictness = Enum.Parse(scenario.Strictness, ignoreCase: true); + + var input = EligibilityInput.Create( + scenario.Version, + strictness, + context, + ReleaseChannelSettings.StableChannelName, + ReleaseChannelSettings.Stable, + nameof(VersionSource.PackageJson) + ); + + return EligibilityEvaluator.Evaluate(input, policy); + } + + static string Describe(EligibilityDecision decision) => + string.Join(Environment.NewLine, decision.Results.Select(result => $" {result.Outcome, -4} {result.Message}")); +} diff --git a/src/tests/Build.IntegrationTests/GlobTests.cs b/src/tests/Build.UnitTests/GlobTests.cs similarity index 100% rename from src/tests/Build.IntegrationTests/GlobTests.cs rename to src/tests/Build.UnitTests/GlobTests.cs diff --git a/src/tests/Build.UnitTests/Infra/GoldenScenario.cs b/src/tests/Build.UnitTests/Infra/GoldenScenario.cs new file mode 100644 index 0000000..3df6d73 --- /dev/null +++ b/src/tests/Build.UnitTests/Infra/GoldenScenario.cs @@ -0,0 +1,106 @@ +using Purview.Build.Configuration; +using Purview.Build.Release; + +namespace Purview.Build.Infra; + +/// +/// The one fixed scenario the release-explain golden file records: a Model C servicing +/// release of 2.0.2 from release/2.0, evaluated against a simulated tag list. +/// +/// +/// Chosen because it exercises the whole report — a non-default policy, an inherited rule set, a +/// simulated context, a prerelease-free stable line, and every rule passing — so a change anywhere +/// in the contract moves the golden file. +/// +static class GoldenScenario +{ + public const string Version = "2.0.2"; + + public const string Ref = "refs/heads/release/2.0"; + + public static async Task RenderAsync(CancellationToken cancellationToken) + { + var root = Path.Combine(Path.GetTempPath(), "purview-build-golden-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var explanation = await BuildAsync(root, cancellationToken); + + return explanation.ToJson(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task BuildAsync(string root, CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(root, "package.json"), + $$"""{ "name": "golden", "version": "{{Version}}" }""", + cancellationToken + ); + + var tagsPath = Path.Combine(root, "tags.txt"); + await File.WriteAllLinesAsync(tagsPath, ["v2.0.0", "v2.0.1", "v2.1.0-prerelease.1"], cancellationToken); + + var configPath = Path.Combine(root, "purview-build.json"); + await File.WriteAllTextAsync( + configPath, + $$""" + { + "Build": { "Solution": "src/Product.slnx" }, + "Release": { + "Mode": "NuGet", + "Eligibility": { "Policy": "TrunkReservesMinor" }, + "Context": { + "Ref": "{{Ref}}", + "ExistingTags": "{{tagsPath.Replace('\\', '/')}}" + } + } + } + """, + cancellationToken + ); + + PipelineStartup startup = new( + PipelineDirectory: AppSettings.Directory, + RepositoryRoot: root, + Resolution: ConfigFileLocator.Resolve(root) + ); + + var configuration = ConfigurationChain.Build(startup, []); + + return await ReleaseExplainer.ExplainAsync(startup, configuration, cancellationToken); + } +} + +/// +/// Locates the shipped appsettings.json directory for tests that build a configuration chain +/// without running the tool. +/// +static class AppSettings +{ + public static string Directory { get; } = Find(); + + static string Find() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + var candidate = Path.Combine(directory.FullName, "src", "src", "Build"); + if (File.Exists(Path.Combine(candidate, "appsettings.json"))) + return candidate; + } + + throw new InvalidOperationException( + $"Could not locate src/src/Build/appsettings.json walking up from '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs b/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs new file mode 100644 index 0000000..4c6517e --- /dev/null +++ b/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using Purview.Build.Configuration; +using Purview.Build.Helpers; + +namespace Purview.Build.Infra; + +/// +/// A throwaway repository on disk for one configuration-resolution scenario: a root +/// package.json, the scenario's configuration files, and the environment the scenario +/// describes. +/// +/// +/// Resolution is exercised against a real filesystem rather than a mock, because the behaviour under +/// test is entirely about what exists where. +/// +sealed class ScenarioRepository : IDisposable +{ + readonly ConfigScenario _scenario; + readonly string? _previousConfigVariable; + readonly string? _previousUserConfigVariable; + readonly Dictionary _previousBuildAgentVariables; + readonly string? _previousXdgVariable; + readonly string _userConfigHome; + + public ScenarioRepository(ConfigScenario scenario) + { + ArgumentNullException.ThrowIfNull(scenario); + + _scenario = scenario; + + Root = Path.Combine(Path.GetTempPath(), "purview-build-config-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Root); + + // Every probe path is relative to the repository root, which the tool finds by walking up + // to the nearest package.json. + File.WriteAllText( + Path.Combine(Root, "package.json"), + /*lang=json,strict*/ + """{ "name": "scenario", "version": "1.0.0" }""" + ); + + foreach (var relativePath in scenario.Files) + WriteConfigFile(relativePath, malformed: relativePath == scenario.Malformed); + + _userConfigHome = Path.Combine(Root, ".user-config-home"); + + _previousConfigVariable = Environment.GetEnvironmentVariable(ConfigFileLocator.EnvironmentVariableName); + _previousUserConfigVariable = Environment.GetEnvironmentVariable( + ConfigFileLocator.UserConfigEnvironmentVariableName + ); + _previousBuildAgentVariables = ExecutionEnvironment.BuildAgentVariables.ToDictionary( + variable => variable, + Environment.GetEnvironmentVariable + ); + _previousXdgVariable = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + + ApplyEnvironment(); + } + + public string Root { get; } + + public string FullPath(string relativePath) => + Path.GetFullPath(Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + /// + /// Resolves configuration the way the tool does, capturing a failure rather than throwing so a + /// scenario can assert on an expected error. + /// + public ResolutionOutcome Resolve() + { + try + { + var resolution = ConfigFileLocator.Resolve( + Root, + _scenario.Config is null ? null : FullPath(_scenario.Config), + _scenario.UserConfig + ); + + if (resolution.Path is not null) + JsonConfigFile.Validate(resolution.Path); + + return new(resolution, null); + } + catch (InvalidOperationException exception) + { + return new(null, exception); + } + } + + /// + /// Reads Build:Solution out of the resolved file, to prove relative-path anchoring. + /// + public string ReadSolution(string configPath) + { + using var document = JsonDocument.Parse(File.ReadAllText(configPath)); + + return document.RootElement.GetProperty("Build").GetProperty("Solution").GetString()!; + } + + void WriteConfigFile(string relativePath, bool malformed) + { + var path = FullPath(relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + if (malformed) + { + // A truncated object: valid-looking enough to be selected, invalid enough to fail parsing. + File.WriteAllText(path, "{ \"Build\": { \"Solution\": \"src/Product.slnx\" "); + return; + } + + // The marker is the relative path, so a test can tell WHICH file was loaded, and + // Build:Solution is identical everywhere so anchoring can be asserted. + var json = $$""" + { + "Build": { "Solution": "src/Product.slnx" }, + "$marker": "{{relativePath}}" + } + """; + + File.WriteAllText(path, json); + } + + void ApplyEnvironment() + { + Environment.SetEnvironmentVariable( + ConfigFileLocator.EnvironmentVariableName, + _scenario.Env is null ? null : FullPath(_scenario.Env) + ); + + // The opt-in flag is passed explicitly to Resolve, so the variable is always cleared here to + // keep the two paths independent. + Environment.SetEnvironmentVariable(ConfigFileLocator.UserConfigEnvironmentVariableName, null); + + // Locality decides whether user configuration applies at all. Every known build-agent + // variable must be neutralised, not just GITHUB_ACTIONS: a CI runner also sets CI, and + // leaving it set makes a "local" scenario look like a build agent. + foreach (var variable in ExecutionEnvironment.BuildAgentVariables) + Environment.SetEnvironmentVariable(variable, _scenario.IsLocal ? null : "true"); + + if (_scenario.UserConfigFile) + { + var userConfig = Path.Combine(_userConfigHome, "purview-build", "purview-build.json"); + Directory.CreateDirectory(Path.GetDirectoryName(userConfig)!); + File.WriteAllText( + userConfig, + /*lang=json,strict*/ + """{ "PublishLocalNuGet": { "LocalFeedPath": "/tmp/scenario-feed" } }""" + ); + + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", _userConfigHome); + } + else + { + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", _userConfigHome); + } + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(ConfigFileLocator.EnvironmentVariableName, _previousConfigVariable); + Environment.SetEnvironmentVariable( + ConfigFileLocator.UserConfigEnvironmentVariableName, + _previousUserConfigVariable + ); + foreach (var (variable, value) in _previousBuildAgentVariables) + Environment.SetEnvironmentVariable(variable, value); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", _previousXdgVariable); + + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } +} + +/// +/// A resolution attempt: either a resolution, or the failure it produced. +/// +sealed record ResolutionOutcome(ConfigResolution? Resolution, InvalidOperationException? Error); diff --git a/src/tests/Build.UnitTests/Infra/Scenarios.cs b/src/tests/Build.UnitTests/Infra/Scenarios.cs new file mode 100644 index 0000000..9d55cd6 --- /dev/null +++ b/src/tests/Build.UnitTests/Infra/Scenarios.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Purview.Build.Settings; + +namespace Purview.Build.Infra; + +/// +/// Loads the declarative scenario files that both the TUnit suites and the just CLI matrices +/// read, so a new case is appended to one JSON file rather than written as test code. +/// +public static class Scenarios +{ + static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// The repository's src/tests/fixtures directory, found by walking up to the repository + /// root the same way the tool does. + /// + public static string FixturesDirectory { get; } = FindFixturesDirectory(); + + public static EligibilityScenarioFile Eligibility { get; } = + Load("eligibility-scenarios.json"); + + public static ConfigScenarioFile ConfigResolution { get; } = + Load("config-resolution-scenarios.json"); + + static T Load(string fileName) + { + var path = Path.Combine(FixturesDirectory, fileName); + var json = File.ReadAllText(path); + + return JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidOperationException($"'{path}' deserialised to null."); + } + + static string FindFixturesDirectory() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + var candidate = Path.Combine(directory.FullName, "src", "tests", "fixtures"); + if (Directory.Exists(candidate)) + return candidate; + } + + throw new InvalidOperationException( + $"Could not locate src/tests/fixtures walking up from '{AppContext.BaseDirectory}'." + ); + } +} + +public sealed record EligibilityScenarioFile +{ + public IReadOnlyList Scenarios { get; init; } = []; + + public Dictionary Policies { get; init; } = []; +} + +public sealed record EligibilityScenario +{ + public string Name { get; init; } = string.Empty; + + public string Why { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("ref")] + public string Ref { get; init; } = string.Empty; + + public string Policy { get; init; } = string.Empty; + + public string Strictness { get; init; } = "NuGet"; + + public IReadOnlyList ExistingTags { get; init; } = []; + + /// Null means the feed was not consulted. + public IReadOnlyList? PublishedVersions { get; init; } + + public ExpectedEligibility Expected { get; init; } = new(); + + public override string ToString() => Name; +} + +public sealed record ExpectedEligibility +{ + public string Verdict { get; init; } = string.Empty; + + public string? RuleId { get; init; } + + public int ExitCode { get; init; } +} + +public sealed record ConfigScenarioFile +{ + public IReadOnlyList Scenarios { get; init; } = []; +} + +public sealed record ConfigScenario +{ + public string Name { get; init; } = string.Empty; + + public string Why { get; init; } = string.Empty; + + public IReadOnlyList Files { get; init; } = []; + + /// Relative path of a listed file to write as malformed JSON. + public string? Malformed { get; init; } + + public string? Config { get; init; } + + public string? Env { get; init; } + + public bool UserConfig { get; init; } + + public bool IsLocal { get; init; } = true; + + public bool UserConfigFile { get; init; } + + public ExpectedConfig Expected { get; init; } = new(); + + public override string ToString() => Name; +} + +public sealed record ExpectedConfig +{ + public string? ResolvedPath { get; init; } + + public IReadOnlyList ShadowWarnings { get; init; } = []; + + public int ExitCode { get; init; } + + public string? ErrorContains { get; init; } + + public string? NearMissContains { get; init; } + + public bool? UserConfigActive { get; init; } +} diff --git a/src/tests/Build.UnitTests/ReleaseExplainGoldenTests.cs b/src/tests/Build.UnitTests/ReleaseExplainGoldenTests.cs new file mode 100644 index 0000000..348d3eb --- /dev/null +++ b/src/tests/Build.UnitTests/ReleaseExplainGoldenTests.cs @@ -0,0 +1,92 @@ +using System.Text.RegularExpressions; +using Purview.Build.Infra; + +namespace Purview.Build; + +/// +/// Pins the release-explain --format=json contract against a golden file. +/// +/// +/// The reusable release workflow parses verdict, releaseMode, decidedByRule and +/// message from this output, and consuming repositories pin the workflow by ref. A change in +/// shape or in a rule message is therefore a change to a published contract, and has to surface in +/// review rather than in a consumer's release. +/// +/// Regenerate with just release-explain-golden after deliberately changing the contract. +/// +[NotInParallel] +public partial class ReleaseExplainGoldenTests +{ + const string GoldenFileName = "release-explain.golden.json"; + + [Test] + public async Task Explain_GivenTheGoldenScenario_MatchesTheRecordedContract(CancellationToken cancellationToken) + { + // Arrange + var expectedPath = Path.Combine(Scenarios.FixturesDirectory, GoldenFileName); + + // Act + var actual = await GoldenScenario.RenderAsync(cancellationToken); + + // Assert + // Regeneration is deliberately opt-in: a golden file that rewrites itself on mismatch + // records the change instead of reporting it. + if (Environment.GetEnvironmentVariable("PURVIEW_BUILD_UPDATE_GOLDEN") == "1") + { + // Written normalised: the scenario runs in a throwaway temp directory, so the raw output + // carries a one-off absolute path that would be pure noise in the committed file. + await File.WriteAllTextAsync(expectedPath, Normalise(actual) + Environment.NewLine, cancellationToken); + } + + await Assert + .That(File.Exists(expectedPath)) + .IsTrue() + .Because($"the golden file '{expectedPath}' must exist; regenerate it if it was removed."); + + var expected = Normalise(await File.ReadAllTextAsync(expectedPath, cancellationToken)); + + await Assert + .That(Normalise(actual)) + .IsEqualTo(expected) + .Because( + "release-explain --format=json is a published contract. If this change is intentional, " + + "regenerate the golden file and review the diff." + ); + } + + [Test] + public async Task Explain_GivenTheGoldenScenario_ExposesEveryFieldTheWorkflowParses( + CancellationToken cancellationToken + ) + { + // Arrange + // Guards the specific property names the YAML reads with jq, independently of the golden + // file's whole-document comparison. + var json = await GoldenScenario.RenderAsync(cancellationToken); + + // Act + using var document = System.Text.Json.JsonDocument.Parse(json); + var root = document.RootElement; + + // Assert + await Assert.That(root.TryGetProperty("verdict", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("exitCode", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("releaseMode", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("message", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("decidedByRule", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("simulated", out _)).IsTrue(); + } + + /// + /// Replaces machine-specific text so the golden file is stable across machines: absolute paths + /// become a placeholder and line endings are normalised. + /// + static string Normalise(string json) => + AbsolutePaths() + // Quoted, so the golden file stays valid JSON and can be reviewed and parsed as such. + .Replace(json.Replace("\r\n", "\n", StringComparison.Ordinal), "\"\"") + .Trim(); + + [GeneratedRegex(@"""[A-Za-z]:\\\\[^""]*""|""/(?:tmp|var|home)/[^""]*""")] + private static partial Regex AbsolutePaths(); +} diff --git a/src/tests/Build.UnitTests/ReleaseOnMainCharacterisationTests.cs b/src/tests/Build.UnitTests/ReleaseOnMainCharacterisationTests.cs new file mode 100644 index 0000000..4d78625 --- /dev/null +++ b/src/tests/Build.UnitTests/ReleaseOnMainCharacterisationTests.cs @@ -0,0 +1,191 @@ +using NuGet.Versioning; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// Pins the pre-change release decision so the extracted evaluator cannot alter it. +/// +/// +/// Before this change the decision was made entirely in YAML (purview-release.yml, step +/// "Check for version bump"): read package.json's version, and skip the release when +/// v{version} already resolves as a git ref, otherwise release with Release__Mode=NuGet. +/// The version itself was validated only by VersionModule's , +/// which accepts four-part ("legacy") versions — two live consumers depend on that (aspirec4 at +/// 13.5.3.10, build-sdk at 1.0.2.2). The evaluated ref was never consulted by the pipeline; the +/// caller's on: block was the only gate. +/// +/// The expectations below are therefore the OLD behaviour, transcribed, not the new rules' output. +/// The ReleaseOnMain policy must reproduce every row. +/// +public class ReleaseOnMainCharacterisationTests +{ + static readonly string[] ReleaseOnMainRules = ["REL001", "REL002", "REL003"]; + + // The release design's sample JSON shows StableRefs as ["refs/heads/main"] only. That would + // reject the documented main-as-head model, whose release head is `release` and which configures + // no eligibility section to opt out with, so the shipped policy lists both heads. + static readonly string[] ReleaseOnMainStableRefs = ["refs/heads/main", "refs/heads/release"]; + + // Model A: every consuming repository releases from refs/heads/main. + const string ModelARef = "refs/heads/main"; + + // Model B: documented main-as-head/release-branch model. No repository uses it today, so the + // expectation is the documented behaviour, which is identical bar the ref. + const string ModelBRef = "refs/heads/release"; + + static EligibilityDecision Decide(string version, string gitRef, params string[] existingTags) => + EligibilityEvaluator.Evaluate( + EligibilityInput.ForCharacterisation(version, gitRef, existingTags), + EligibilityPolicies.Resolve(EligibilitySettings.Default, EligibilityPolicies.ReleaseOnMainName) + ); + + [Test] + [Arguments(ModelARef)] + [Arguments(ModelBRef)] + public async Task Evaluate_GivenUntaggedStableVersion_Releases(string gitRef) + { + // Arrange + // Model A/B with a bumped version and no matching tag: the old YAML set should_release=true. + + // Act + var decision = Decide("1.0.0", gitRef); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + await Assert.That(decision.ExitCode).IsEqualTo(0); + } + + [Test] + [Arguments(ModelARef)] + [Arguments(ModelBRef)] + public async Task Evaluate_GivenAlreadyTaggedVersion_SkipsWithExitCodeZero(string gitRef) + { + // Arrange + // The old YAML's `git rev-parse "$TAG"` succeeded, printed "already tagged. Skipping + // release." and left the job green. + + // Act + var decision = Decide("1.0.0", gitRef, "v1.0.0"); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Skip); + await Assert.That(decision.ExitCode).IsEqualTo(0); + await Assert.That(decision.RuleId).IsEqualTo("REL002"); + } + + [Test] + public async Task Evaluate_GivenUnrelatedTags_Releases() + { + // Arrange + // Only an exact v{version} match gated the old release. + + // Act + var decision = Decide("0.3.7", ModelARef, "v0.3.5", "v0.3.6", "v0.2.1"); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + public async Task Evaluate_GivenPrereleaseVersionOnMain_Releases() + { + // Arrange + // sourcegenerator-framework (1.0.0-prerelease.54), value-objects and zodsharp all ship + // prereleases from main. REL003 constrains stable versions only, so these must still pass. + + // Act + var decision = Decide("1.0.0-prerelease.54", ModelARef); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + [Arguments("13.5.3.10")] + [Arguments("1.0.2.2")] + public async Task Evaluate_GivenFourPartVersionFromLiveConsumer_Releases(string version) + { + // Arrange + // aspirec4 and build-sdk ship four-part versions today: NuGetVersion.TryParse accepts them + // and REL006 is not in ReleaseOnMain, so they must keep releasing unchanged. + + // Act + var decision = Decide(version, ModelARef); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + public async Task Evaluate_GivenNonZeroPatchOnMain_Releases() + { + // Arrange + // REL004 (servicing refs) is NOT enabled in ReleaseOnMain, so a patch release from main — + // which is how every repository ships fixes today — must not be blocked. + + // Act + var decision = Decide("5.0.1", ModelARef); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + public async Task Evaluate_GivenVersionLowerThanAnExistingTag_Releases() + { + // Arrange + // REL005 (monotonic versions) is NOT enabled in ReleaseOnMain. The old YAML only ever + // compared for tag existence, so a regression released. + + // Act + var decision = Decide("1.0.0", ModelARef, "v2.0.0"); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Release); + } + + [Test] + public async Task Evaluate_GivenUnparseableVersion_FailsWithExitCodeOne() + { + // Arrange + // VersionModule threw "The version 'x' in package.json is not a valid SemVer." and the tool + // exited 1. + + // Act + var decision = Decide("not-a-version", ModelARef); + + // Assert + await Assert.That(decision.Verdict).IsEqualTo(EligibilityVerdict.Fail); + await Assert.That(decision.ExitCode).IsEqualTo(1); + await Assert.That(decision.RuleId).IsEqualTo("REL001"); + } + + [Test] + public async Task ReleaseOnMain_EnablesExactlyTheRulesThatReproduceTodaysBehaviour() + { + // Arrange + var policy = EligibilityPolicies.Resolve(EligibilitySettings.Default, EligibilityPolicies.ReleaseOnMainName); + + // Act + var ruleIds = policy.RuleIds; + + // Assert + await Assert.That(ruleIds).IsEquivalentTo(ReleaseOnMainRules); + await Assert.That(policy.StableRefs).IsEquivalentTo(ReleaseOnMainStableRefs); + } + + [Test] + public async Task ReleaseOnMain_IsTheDefaultPolicyWhenEligibilityIsAbsent() + { + // Arrange + ReleaseSettings settings = new(); + + // Act + var policyName = settings.Eligibility.Policy; + + // Assert + await Assert.That(policyName).IsEqualTo(EligibilityPolicies.ReleaseOnMainName); + } +} diff --git a/src/tests/Build.UnitTests/ReleasePublicationTests.cs b/src/tests/Build.UnitTests/ReleasePublicationTests.cs new file mode 100644 index 0000000..ce6580d --- /dev/null +++ b/src/tests/Build.UnitTests/ReleasePublicationTests.cs @@ -0,0 +1,240 @@ +using NuGet.Versioning; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// The publication split: Release:Mode as a preset, with explicitly set +/// Release:Publish/Release:GitHubRelease and channel settings overriding it. +/// +public class ReleasePublicationTests +{ + [Test] + [Arguments(ReleaseMode.None, false, false)] + [Arguments(ReleaseMode.NuGet, true, true)] + [Arguments(ReleaseMode.GitHubRelease, false, true)] + [Arguments(ReleaseMode.LocalNuGet, false, false)] + public async Task Mode_GivenNoExplicitSwitches_DerivesTodaysBehaviour( + ReleaseMode mode, + bool expectedPublish, + bool expectedGitHubRelease + ) + { + // Arrange + // These four rows are the pre-change skip conditions of PublishNuGetModule and + // CreateGitHubReleaseModule, so the preset must keep deriving exactly them. + ReleaseSettings settings = new() { Mode = mode }; + + // Act + var publish = settings.ShouldPublish(); + var githubRelease = settings.ShouldCreateGitHubRelease(); + + // Assert + await Assert.That(publish).IsEqualTo(expectedPublish); + await Assert.That(githubRelease).IsEqualTo(expectedGitHubRelease); + } + + [Test] + public async Task Publish_GivenExplicitTrueAndModeNone_WinsOverThePreset() + { + // Arrange + ReleaseSettings settings = new() { Mode = ReleaseMode.None, Publish = true }; + + // Act + var publish = settings.ShouldPublish(); + + // Assert + await Assert.That(publish).IsTrue(); + } + + [Test] + public async Task Publish_GivenExplicitFalseAndModeNuGet_WinsOverThePreset() + { + // Arrange + ReleaseSettings settings = new() { Mode = ReleaseMode.NuGet, Publish = false }; + + // Act + var publish = settings.ShouldPublish(); + + // Assert + await Assert.That(publish).IsFalse(); + await Assert.That(settings.ShouldCreateGitHubRelease()).IsTrue().Because("the two switches are independent."); + } + + [Test] + public async Task GitHubRelease_GivenExplicitFalseAndModeNuGet_PublishesWithoutReleasing() + { + // Arrange + ReleaseSettings settings = new() { Mode = ReleaseMode.NuGet, GitHubRelease = false }; + + // Act + var githubRelease = settings.ShouldCreateGitHubRelease(); + + // Assert + await Assert.That(githubRelease).IsFalse(); + await Assert.That(settings.ShouldPublish()).IsTrue(); + } + + [Test] + public async Task GitHubRelease_GivenChannelOptOut_SuppressesTheRelease() + { + // Arrange + // A preview channel pointing at a different feed with GitHubRelease: false makes + // dispatch-from-any-branch preview packages possible without cutting a GitHub release. + ReleaseSettings settings = new() + { + Mode = ReleaseMode.NuGet, + Channel = "preview", + Channels = new() + { + ["preview"] = new() + { + FeedUrl = "https://nuget.pkg.github.com/purview-dev/index.json", + GitHubRelease = false, + }, + }, + }; + + // Act + var githubRelease = settings.ShouldCreateGitHubRelease(); + + // Assert + await Assert.That(githubRelease).IsFalse(); + await Assert.That(settings.ShouldPublish()).IsTrue(); + await Assert + .That(settings.ResolveChannel().FeedUrl) + .IsEqualTo("https://nuget.pkg.github.com/purview-dev/index.json"); + } + + [Test] + public async Task GitHubRelease_GivenExplicitKeyAndChannelOptOut_PrefersTheExplicitKey() + { + // Arrange + // Explicitly set keys always win, including over a channel's own setting. + ReleaseSettings settings = new() + { + Mode = ReleaseMode.None, + GitHubRelease = true, + Channel = "preview", + Channels = new() { ["preview"] = new() { GitHubRelease = false } }, + }; + + // Act + var githubRelease = settings.ShouldCreateGitHubRelease(); + + // Assert + await Assert.That(githubRelease).IsTrue(); + } + + [Test] + public async Task ResolveChannel_GivenAnUndeclaredChannel_FallsBackToStableDefaults() + { + // Arrange + ReleaseSettings settings = new() { Channel = "does-not-exist" }; + + // Act + var channel = settings.ResolveChannel(); + + // Assert + await Assert.That(channel.FeedUrl).IsNull(); + await Assert.That(channel.GitHubRelease).IsNull(); + await Assert.That(channel.MarkPrerelease).IsNull(); + } + + [Test] + public async Task ShouldMarkPrerelease_GivenChannelOverride_PrefersTheChannel() + { + // Arrange + ReleaseSettings settings = new() + { + MarkPrerelease = true, + Channel = "preview", + Channels = new() { ["preview"] = new() { MarkPrerelease = false } }, + }; + + // Act + var marked = settings.ShouldMarkPrerelease(NuGetVersion.Parse("2.1.0-prerelease.1")); + + // Assert + await Assert.That(marked).IsFalse(); + } + + [Test] + public async Task ValidateSimulationInterlock_GivenSimulatedContextAndRealPublish_Fails() + { + // Arrange + // Simulated tags selecting which versions are "already released" while really pushing + // packages is the one combination that can do damage from a developer machine. + ReleaseSettings settings = new() + { + Mode = ReleaseMode.NuGet, + Context = new() { Ref = "refs/heads/release/2.0" }, + }; + + // Act + var act = settings.ValidateSimulationInterlock; + + // Assert + var exception = await Assert.That(act).Throws(); + await Assert.That(exception!.Message).Contains("simulated release context"); + await Assert.That(exception!.Message).Contains("release-explain"); + } + + [Test] + public async Task ValidateSimulationInterlock_GivenSimulatedContextAndLocalNuGet_Allows() + { + // Arrange + // LocalNuGet never touches a remote feed, so simulating against it is safe and useful. + ReleaseSettings settings = new() + { + Mode = ReleaseMode.LocalNuGet, + Context = new() { Ref = "refs/heads/release/2.0" }, + }; + + // Act + settings.ValidateSimulationInterlock(); + + // Assert + await Assert.That(settings.ShouldPublish()).IsFalse(); + } + + [Test] + public async Task ValidateSimulationInterlock_GivenNoSimulatedContext_Allows() + { + // Arrange + ReleaseSettings settings = new() { Mode = ReleaseMode.NuGet }; + + // Act + settings.ValidateSimulationInterlock(); + + // Assert + await Assert.That(settings.Context.IsSimulated).IsFalse(); + } + + [Test] + public async Task DryRun_GivenDefaults_IsOff() + { + // Arrange + ReleaseSettings settings = new(); + + // Act + var dryRun = settings.DryRun; + + // Assert + await Assert.That(dryRun).IsFalse(); + } + + [Test] + public async Task Eligibility_GivenDefaults_SelectsReleaseOnMain() + { + // Arrange + ReleaseSettings settings = new(); + + // Act + var policy = settings.Eligibility.Policy; + + // Assert + await Assert.That(policy).IsEqualTo("ReleaseOnMain"); + await Assert.That(settings.Channel).IsEqualTo("stable"); + } +} diff --git a/src/tests/Build.IntegrationTests/ReleaseSettingsTests.cs b/src/tests/Build.UnitTests/ReleaseSettingsTests.cs similarity index 100% rename from src/tests/Build.IntegrationTests/ReleaseSettingsTests.cs rename to src/tests/Build.UnitTests/ReleaseSettingsTests.cs diff --git a/src/tests/Build.UnitTests/ReleaseUnitTests.cs b/src/tests/Build.UnitTests/ReleaseUnitTests.cs new file mode 100644 index 0000000..89bf934 --- /dev/null +++ b/src/tests/Build.UnitTests/ReleaseUnitTests.cs @@ -0,0 +1,191 @@ +using NuGet.Versioning; +using Purview.Build.Release; +using Purview.Build.Settings; + +namespace Purview.Build; + +public class ReleaseUnitTests +{ + static readonly string[] ExpectedConcurrentLines = ["2.0", "2.1"]; + + static ReleaseUnit Unit(string version) => + ReleaseUnitFactory.TryCreate( + version, + ReleaseChannelSettings.StableChannelName, + nameof(VersionSource.PackageJson) + )!; + + [Test] + [Arguments("2.0.2", "2.0")] + [Arguments("2.1.0-prerelease.1", "2.1")] + [Arguments("13.5.3.10", "13.5")] + [Arguments("1.0.0", "1.0")] + public async Task LineOf_GivenVersion_IsTheMajorMinorPair(string version, string expectedLine) + { + // Arrange + // The line is what REL005 scopes monotonicity to, so a serviced stable line and an + // in-flight prerelease line never block each other. + + // Act + var line = Unit(version).Line; + + // Assert + await Assert.That(line).IsEqualTo(expectedLine); + } + + [Test] + [Arguments("2.1.0-prerelease.1", "prerelease")] + [Arguments("2.1.0-preview.4", "preview")] + [Arguments("2.1.0-rc1", "rc1")] + public async Task LabelOf_GivenPrerelease_DropsTheNumericCounter(string version, string expectedLabel) + { + // Arrange + + // Act + var label = Unit(version).PrereleaseLabel; + + // Assert + await Assert.That(label).IsEqualTo(expectedLabel); + } + + [Test] + public async Task LabelOf_GivenStableVersion_IsNull() + { + // Arrange + + // Act + var label = Unit("2.0.0").PrereleaseLabel; + + // Assert + await Assert.That(label).IsNull(); + } + + [Test] + [Arguments("13.5.3.10", "v13.5.3.10")] + [Arguments("1.0.0.0", "v1.0.0.0")] + [Arguments("2.0.2", "v2.0.2")] + public async Task Tag_PreservesTheVersionExactlyAsWritten(string version, string expectedTag) + { + // Arrange + // The workflow's old gate computed TAG="v$VERSION" from the raw package.json text. A + // normalised tag would drop a trailing ".0" revision and desynchronise the two. + + // Act + var tag = Unit(version).Tag; + + // Assert + await Assert.That(tag).IsEqualTo(expectedTag); + } + + [Test] + [Arguments("2.0.1.1", true)] + [Arguments("13.5.3.10", true)] + [Arguments("1.0.0", false)] + [Arguments("2.1.0-prerelease.1", false)] + [Arguments("1.0.0+abc", false)] + public async Task IsFourPart_GivenVersion_DetectsFourNumericComponents(string version, bool expected) + { + // Arrange + + // Act + var fourPart = ReleaseUnitFactory.IsFourPart(version); + + // Assert + await Assert.That(fourPart).IsEqualTo(expected); + } + + [Test] + [Arguments("13.5.3.10", VersionStrictness.NuGet, true)] + [Arguments("13.5.3.10", VersionStrictness.SemVer2, false)] + [Arguments("1.0.0", VersionStrictness.SemVer2, true)] + [Arguments("1.0", VersionStrictness.NuGet, true)] + [Arguments("1.0", VersionStrictness.SemVer2, false)] + [Arguments("not-a-version", VersionStrictness.NuGet, false)] + public async Task SatisfiesStrictness_GivenVersionAndStrictness_JudgesTheText( + string version, + VersionStrictness strictness, + bool expected + ) + { + // Arrange + // NuGet strictness is the default because it is what the pipeline has always accepted. + + // Act + var satisfies = ReleaseUnitFactory.SatisfiesStrictness(version, strictness); + + // Assert + await Assert.That(satisfies).IsEqualTo(expected); + } + + [Test] + public async Task TryCreate_GivenUnparseableVersion_ReturnsNull() + { + // Arrange + + // Act + var unit = ReleaseUnitFactory.TryCreate( + "nonsense", + ReleaseChannelSettings.StableChannelName, + nameof(VersionSource.PackageJson) + ); + + // Assert + await Assert.That(unit).IsNull(); + } + + [Test] + public async Task ReleaseUnitSet_GivenMultipleUnits_ExposesEveryUnitInOrder() + { + // Arrange + // Every version source yields one unit today. The set is the contract so a future + // multi-unit source needs no change in PackModule or CreateGitHubReleaseModule, both of + // which enumerate Units rather than reading a scalar version. + ReleaseUnitSet set = new( + "2.0.2", + [Unit("2.0.2"), Unit("2.1.0-prerelease.1")], + nameof(VersionSource.PackageJson) + ); + + // Act + var tags = set.Units.Select(unit => unit.Tag).ToList(); + + // Assert + await Assert.That(set.Units).Count().IsEqualTo(2); + await Assert.That(tags[0]).IsEqualTo("v2.0.2"); + await Assert.That(tags[1]).IsEqualTo("v2.1.0-prerelease.1"); + await Assert.That(set.Primary.Tag).IsEqualTo("v2.0.2"); + await Assert.That(set.Version).IsEqualTo(NuGetVersion.Parse("2.0.2")); + } + + [Test] + public async Task ReleaseUnitSet_GivenMultipleUnits_KeepsLinesAndChannelsIndependent() + { + // Arrange + ReleaseUnitSet set = new( + "2.0.2", + [Unit("2.0.2"), Unit("2.1.0-prerelease.1")], + nameof(VersionSource.PackageJson) + ); + + // Act + var lines = set.Units.Select(unit => unit.Line).ToList(); + + // Assert + await Assert.That(lines).IsEquivalentTo(ExpectedConcurrentLines); + await Assert.That(set.Units[0].IsPrerelease).IsFalse(); + await Assert.That(set.Units[1].IsPrerelease).IsTrue(); + } + + [Test] + public async Task ReleaseUnitSet_GivenNoUnits_FailsWhenThePrimaryIsRead() + { + // Arrange + ReleaseUnitSet set = new("1.0.0", [], nameof(VersionSource.PackageJson)); + + // Act + ReleaseUnit Act() => set.Primary; + + // Assert + await Assert.That(Act).Throws(); + } +} diff --git a/src/tests/Build.IntegrationTests/ToolCLITests.cs b/src/tests/Build.UnitTests/ToolCLITests.cs similarity index 100% rename from src/tests/Build.IntegrationTests/ToolCLITests.cs rename to src/tests/Build.UnitTests/ToolCLITests.cs diff --git a/src/tests/Build.IntegrationTests/WebScriptsTests.cs b/src/tests/Build.UnitTests/WebScriptsTests.cs similarity index 100% rename from src/tests/Build.IntegrationTests/WebScriptsTests.cs rename to src/tests/Build.UnitTests/WebScriptsTests.cs diff --git a/src/tests/Build.UnitTests/WorkflowParityTests.cs b/src/tests/Build.UnitTests/WorkflowParityTests.cs new file mode 100644 index 0000000..ade55c3 --- /dev/null +++ b/src/tests/Build.UnitTests/WorkflowParityTests.cs @@ -0,0 +1,334 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Purview.Build.Configuration; +using Purview.Build.Settings; + +namespace Purview.Build; + +/// +/// Holds the YAML surface honest: every configuration key the reusable workflows and the composite +/// action forward must exist on a settings class, and every optional input must be forwarded only +/// when the caller actually provided it. +/// +/// +/// The second half guards the empty-env-var hazard fixed in commit 4d72bf7: environment +/// variables outrank purview-build.json, so forwarding an unset input as the empty string +/// silently erases a consuming repository's configured value. A typo'd key, meanwhile, binds to +/// nothing at all and fails silently — nothing in the pipeline warns about an unrecognised +/// environment variable. +/// +public partial class WorkflowParityTests +{ + /// + /// The settings sections an environment key may address, by configuration section name. + /// + /// + /// Compared case-insensitively, because .NET configuration keys are: the environment provider + /// maps NUGET__APIKEY onto NuGet:APIKey regardless of casing, and the historical + /// secret name consuming repositories use differs in case from the section name. + /// + static readonly Dictionary Sections = new(StringComparer.OrdinalIgnoreCase) + { + [BuildSettings.SectionName] = typeof(BuildSettings), + [PackValidationSettings.SectionName] = typeof(PackValidationSettings), + [NuGetSettings.SectionName] = typeof(NuGetSettings), + [PublishLocalNuGetSettings.SectionName] = typeof(PublishLocalNuGetSettings), + [GitHubSettings.SectionName] = typeof(GitHubSettings), + [ReleaseSettings.SectionName] = typeof(ReleaseSettings), + [VersionSettings.SectionName] = typeof(VersionSettings), + }; + + /// + /// Environment variables the tool reads directly rather than through the configuration binder. + /// + static readonly string[] ToolEnvironmentVariables = + [ + ConfigFileLocator.EnvironmentVariableName, + ConfigFileLocator.UserConfigEnvironmentVariableName, + "GITHUB_TOKEN", + "NUGET_APIKEY", + "NUGET_API_KEY", + "LOCAL_NUGET_FEED_PATH", + "PURVIEW_BUILD_STACKTRACE", + "MODULAR_PIPELINES_DIRECTORY", + ]; + + static readonly string[] WorkflowFiles = + [ + ".github/workflows/purview-build.yml", + ".github/workflows/purview-release.yml", + ".github/workflows/release.yml", + ".github/actions/purview-build/action.yml", + ]; + + public static IEnumerable> Workflows() => + WorkflowFiles.Select>(file => () => file); + + [Test] + [MethodDataSource(nameof(Workflows))] + public async Task Workflow_ForwardsOnlyKeysThatExistInTheSettingsClasses(string workflowFile) + { + // Arrange + var content = ReadWorkflow(workflowFile); + + // Act + var keys = SettingsKeys(content); + + // Assert + foreach (var key in keys) + { + await Assert + .That(Resolves(key)) + .IsTrue() + .Because( + $"{workflowFile} forwards '{key}', which binds to no settings property. An " + + "unrecognised environment variable is silently ignored by the configuration " + + "binder, so the setting would never take effect." + ); + } + } + + [Test] + [MethodDataSource(nameof(Workflows))] + public async Task Workflow_ForwardsEveryOptionalInputOnlyWhenNonEmpty(string workflowFile) + { + // Arrange + var content = ReadWorkflow(workflowFile); + var optionalInputs = OptionalInputs(content); + + // Act + var directlyMapped = optionalInputs.Where(input => DirectEnvMapping(input).IsMatch(content)).ToList(); + + // Assert + await Assert + .That(directlyMapped) + .IsEmpty() + .Because( + $"{workflowFile} maps optional input(s) {string.Join(", ", directlyMapped)} straight into " + + "env:. An input the caller omitted is forwarded as the empty string, which overrides " + + "the repository's purview-build.json because env vars win. Guard it with " + + "`if [ -n \"${{ inputs. }}\" ]` instead." + ); + } + + [Test] + [MethodDataSource(nameof(Workflows))] + public async Task Workflow_GuardsEveryOptionalInputItForwards(string workflowFile) + { + // Arrange + var content = ReadWorkflow(workflowFile); + var optionalInputs = OptionalInputs(content); + + // Act + // An optional input that is referenced outside its own declaration must be referenced + // inside a non-empty guard. + var referenced = optionalInputs.Where(input => InputReference(input).Count(content) > 0).ToList(); + + // Assert + foreach (var input in referenced) + { + await Assert + .That(NonEmptyGuard(input).IsMatch(content)) + .IsTrue() + .Because( + $"{workflowFile} references optional input '{input}' without an " + + $"`if [ -n \"${{{{ inputs.{input} }}}}\" ]` guard." + ); + } + } + + [Test] + public async Task ReleaseWorkflow_DelegatesTheEligibilityDecisionToTheTool() + { + // Arrange + // The gate used to be bash: read package.json, `git rev-parse` the tag, set should_release. + // It now has to be the tool's evaluation, or the logic is untestable again. + var content = ReadWorkflow(".github/workflows/purview-release.yml"); + + // Act + var usesReleaseExplain = content.Contains("release-explain --format=json", StringComparison.Ordinal); + + // Assert + await Assert.That(usesReleaseExplain).IsTrue(); + await Assert + .That(content) + .DoesNotContain("git rev-parse") + .Because("tag arithmetic in YAML cannot be unit-tested or run locally."); + await Assert + .That(content) + .Contains("steps.eligibility.outputs.release_mode") + .Because("the workflow must set Release__Mode from the evaluated decision."); + } + + [Test] + public async Task ReleaseWorkflow_KeepsTheDeprecatedReleaseBranchInput() + { + // Arrange + // All seven consuming repositories still pass release-branch. Removing it breaks them all. + var content = ReadWorkflow(".github/workflows/purview-release.yml"); + + // Act + var declared = content.Contains("release-branch:", StringComparison.Ordinal); + + // Assert + await Assert.That(declared).IsTrue(); + } + + [Test] + public async Task ReleaseWorkflow_DefinesNoConcurrencyGroup() + { + // Arrange + // A shared group between a caller and the reusable workflow it calls makes GitHub cancel the + // run as a deadlock. Callers own release serialization. + var content = ReadWorkflow(".github/workflows/purview-release.yml"); + + // Act + var hasConcurrency = ConcurrencyBlock().IsMatch(content); + + // Assert + await Assert.That(hasConcurrency).IsFalse(); + } + + [Test] + public async Task ReleaseWorkflow_DeclaresEveryNewInput() + { + // Arrange + var content = ReadWorkflow(".github/workflows/purview-release.yml"); + string[] expected = ["config-path", "eligibility-policy", "release-channel", "version-source"]; + + // Act + var declared = expected.Where(input => content.Contains($"{input}:", StringComparison.Ordinal)); + + // Assert + await Assert.That(declared).IsEquivalentTo(expected); + } + + [Test] + public async Task ParityChecks_ActuallyFindSomethingToCheck() + { + // Arrange + // Guards the guards: a regex that silently matches nothing would make every parity + // assertion above pass vacuously. + var content = ReadWorkflow(".github/workflows/purview-release.yml"); + + // Act + var optionalInputs = OptionalInputs(content); + var settingsKeys = SettingsKeys(content); + + // Assert + await Assert.That(optionalInputs).Contains("test-filter"); + await Assert.That(optionalInputs).Contains("config-path"); + await Assert.That(optionalInputs).Contains("eligibility-policy"); + await Assert + .That(optionalInputs) + .DoesNotContain("release-mode") + .Because("release-mode declares a default, so it is never forwarded empty."); + await Assert.That(settingsKeys).Contains("Release__Mode"); + await Assert.That(settingsKeys).Contains("Build__RunTests"); + await Assert.That(settingsKeys).Contains("Release__Eligibility__Policy"); + } + + static string ReadWorkflow(string relativePath) + { + var root = RepositoryRoot(); + var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + return File.ReadAllText(path); + } + + static string RepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if (File.Exists(Path.Combine(directory.FullName, "package.json"))) + return directory.FullName; + } + + throw new InvalidOperationException($"Could not locate the repository root from '{AppContext.BaseDirectory}'."); + } + + /// + /// Every Section__Key style environment variable the workflow sets, excluding the tool's + /// own direct-read variables. + /// + static IReadOnlyList SettingsKeys(string content) => + [ + .. EnvironmentKey() + .Matches(content) + .Select(match => match.Groups["key"].Value) + .Where(key => !ToolEnvironmentVariables.Contains(key, StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal), + ]; + + /// + /// Inputs declared without a default:, which are therefore empty when the caller omits + /// them. + /// + static IReadOnlyList OptionalInputs(string content) => + [ + .. InputDeclaration() + .Matches(content) + .Where(match => !match.Groups["body"].Value.Contains("default:", StringComparison.Ordinal)) + .Select(match => match.Groups["name"].Value) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal), + ]; + + static bool Resolves(string environmentKey) + { + var segments = environmentKey.Split("__", StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length < 2 || !Sections.TryGetValue(segments[0], out var current)) + return false; + + foreach (var segment in segments.Skip(1)) + { + var property = current + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(candidate => + string.Equals(candidate.Name, segment, StringComparison.OrdinalIgnoreCase) + ); + + if (property is null) + return false; + + current = property.PropertyType; + } + + return true; + } + + static Regex DirectEnvMapping(string input) => + new( + @"^\s{2,}[A-Za-z_][A-Za-z0-9_]*__[A-Za-z0-9_]+\s*:\s*\$\{\{\s*inputs\." + Regex.Escape(input) + @"\s*\}\}", + RegexOptions.Multiline, + TimeSpan.FromSeconds(5) + ); + + static Regex InputReference(string input) => + new(@"\$\{\{\s*inputs\." + Regex.Escape(input) + @"\s*\}\}", RegexOptions.None, TimeSpan.FromSeconds(5)); + + static Regex NonEmptyGuard(string input) => + new( + @"if\s+\[\s+-n\s+""\$\{\{\s*inputs\." + Regex.Escape(input) + @"\s*\}\}""\s+\]", + RegexOptions.None, + TimeSpan.FromSeconds(5) + ); + + [GeneratedRegex(@"(?[A-Za-z_][A-Za-z0-9_]*__[A-Za-z0-9_]+(?:__[A-Za-z0-9_]+)*)\s*[:=]")] + private static partial Regex EnvironmentKey(); + + [GeneratedRegex( + @"^ (?[a-z][a-z0-9-]*):\s*$(?(?:\n(?: .*|\s*))*?)(?=^ [a-z]|^ [a-z]|^[a-z])", + RegexOptions.Multiline + )] + private static partial Regex InputDeclaration(); + + [GeneratedRegex(@"^concurrency:", RegexOptions.Multiline)] + private static partial Regex ConcurrencyBlock(); +} diff --git a/src/tests/fixtures/config-resolution-scenarios.json b/src/tests/fixtures/config-resolution-scenarios.json new file mode 100644 index 0000000..dd9652b --- /dev/null +++ b/src/tests/fixtures/config-resolution-scenarios.json @@ -0,0 +1,295 @@ +{ + "$comment": [ + "The single source of truth for configuration-file discovery. Both the data-driven TUnit suite", + "(ConfigResolutionScenarioTests) and the `just config-matrix` recipe, which runs each case", + "through the real CLI, read this file.", + "", + "Fields: files (created relative to a throwaway repository root, each containing a marker", + "setting), config (--config value), env (PURVIEW_BUILD_CONFIG value), userConfig (--user-config),", + "isLocal (whether the run is treated as local). Expected: resolvedPath (relative to the", + "repository root, or null), shadowWarnings (relative paths), exitCode, and optionally", + "userConfigActive and errorContains." + ], + "scenarios": [ + { + "name": "no-config-is-valid-and-silent", + "why": "Every key is optional; absence must not warn.", + "files": [], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "probe-1-repository-root", + "why": "Probe position 1 — the original location, so no existing repository moves.", + "files": ["purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { "resolvedPath": "purview-build.json", "shadowWarnings": [], "exitCode": 0 } + }, + { + "name": "probe-2-dot-config", + "files": [".config/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": ".config/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "probe-3-dot-build", + "files": [".build/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": ".build/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "probe-4-build", + "files": ["build/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "build/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "probe-5-dot-purview", + "files": [".purview/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": ".purview/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "probe-6-dot-github", + "files": [".github/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": ".github/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "first-match-wins-and-shadows-are-warned", + "why": "Silent first-match-wins is how someone spends an afternoon editing a file the tool never reads.", + "files": [ + ".config/purview-build.json", + "purview-build.json", + ".github/purview-build.json" + ], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "purview-build.json", + "shadowWarnings": [".config/purview-build.json", ".github/purview-build.json"], + "exitCode": 0 + } + }, + { + "name": "lower-priority-pair-shadows-correctly", + "why": "The winner is the first in probe order, not the first created.", + "files": ["build/purview-build.json", ".build/purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": ".build/purview-build.json", + "shadowWarnings": ["build/purview-build.json"], + "exitCode": 0 + } + }, + { + "name": "explicit-config-skips-probing", + "why": "An explicit location must not be shadow-warned against files it deliberately bypasses.", + "files": ["purview-build.json", "custom/elsewhere.json"], + "config": "custom/elsewhere.json", + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "custom/elsewhere.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "environment-variable-selects-config", + "files": ["purview-build.json", "custom/from-env.json"], + "config": null, + "env": "custom/from-env.json", + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "custom/from-env.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "command-line-beats-environment-variable", + "files": ["custom/from-cli.json", "custom/from-env.json"], + "config": "custom/from-cli.json", + "env": "custom/from-env.json", + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "custom/from-cli.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "explicit-missing-path-fails", + "why": "Never a silent fallback to probing or to defaults.", + "files": ["purview-build.json"], + "config": "custom/missing.json", + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 1, + "errorContains": "does not exist" + } + }, + { + "name": "explicit-missing-env-path-fails", + "files": ["purview-build.json"], + "config": null, + "env": "custom/missing.json", + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 1, + "errorContains": "PURVIEW_BUILD_CONFIG" + } + }, + { + "name": "explicit-directory-resolves-to-file-within", + "files": ["custom/purview-build.json"], + "config": "custom", + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": "custom/purview-build.json", + "shadowWarnings": [], + "exitCode": 0 + } + }, + { + "name": "malformed-json-fails-naming-the-file", + "why": "A bare deserialisation exception is useless when six probe locations are possible.", + "files": ["purview-build.json"], + "malformed": "purview-build.json", + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 1, + "errorContains": "is not valid JSON" + } + }, + { + "name": "near-miss-filename-is-warned-not-loaded", + "why": "An obvious typo must not read as 'no configuration'.", + "files": ["purview-buidl.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 0, + "nearMissContains": "purview-buidl.json" + } + }, + { + "name": "user-config-ignored-when-not-opted-in", + "files": ["purview-build.json"], + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "userConfigFile": true, + "expected": { + "resolvedPath": "purview-build.json", + "shadowWarnings": [], + "exitCode": 0, + "userConfigActive": false + } + }, + { + "name": "user-config-active-when-opted-in-and-local", + "files": ["purview-build.json"], + "config": null, + "env": null, + "userConfig": true, + "isLocal": true, + "userConfigFile": true, + "expected": { + "resolvedPath": "purview-build.json", + "shadowWarnings": [], + "exitCode": 0, + "userConfigActive": true + } + }, + { + "name": "user-config-ignored-on-a-build-agent-even-when-opted-in", + "why": "It must be impossible to influence a CI build with a machine-local file.", + "files": ["purview-build.json"], + "config": null, + "env": null, + "userConfig": true, + "isLocal": false, + "userConfigFile": true, + "expected": { + "resolvedPath": "purview-build.json", + "shadowWarnings": [], + "exitCode": 0, + "userConfigActive": false + } + } + ] +} diff --git a/src/tests/fixtures/eligibility-scenarios.json b/src/tests/fixtures/eligibility-scenarios.json new file mode 100644 index 0000000..f5669bc --- /dev/null +++ b/src/tests/fixtures/eligibility-scenarios.json @@ -0,0 +1,258 @@ +{ + "$comment": [ + "The single source of truth for release-eligibility behaviour. Both the data-driven TUnit suite", + "(EligibilityScenarioTests) and the `just release-matrix` recipe, which runs each case through", + "the real CLI, read this file. Adding a rule or a policy means appending cases here, not", + "writing test code.", + "", + "Fields: version (as written in package.json), ref (the evaluated git ref), policy, strictness,", + "existingTags, publishedVersions (null means the feed was not consulted), and the expected", + "verdict / ruleId / exitCode. ruleId is the rule that decided the verdict, or null when every", + "rule passed." + ], + "scenarios": [ + { + "name": "model-a-untagged-stable-releases", + "why": "Today's Model A: every consuming repository releases a stable version from main.", + "version": "1.0.0", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-a-already-tagged-skips", + "why": "Replaces the workflow's `git rev-parse` gate. Already released is a skip, not a failure.", + "version": "1.0.0", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": ["v0.9.0", "v1.0.0"], + "publishedVersions": null, + "expected": { "verdict": "Skip", "ruleId": "REL002", "exitCode": 0 } + }, + { + "name": "model-a-prerelease-from-main-releases", + "why": "sourcegenerator-framework, value-objects and zodsharp ship prereleases from main.", + "version": "1.0.0-prerelease.54", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-a-patch-from-main-releases", + "why": "REL004 is not enabled in ReleaseOnMain, so patches keep shipping from main.", + "version": "5.0.1", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-b-stable-from-release-branch-releases", + "why": "The documented main-as-head model releases from the `release` branch.", + "version": "2.0.0", + "ref": "refs/heads/release", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-a-stable-from-feature-branch-fails", + "why": "REL003: a stable version may only come from a declared release head.", + "version": "1.0.0", + "ref": "refs/heads/feature/add-thing", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL003", "exitCode": 1 } + }, + { + "name": "model-c-stable-from-release-line-releases", + "why": "Model C: a serviced stable line releases 2.0.2 from release/2.0.", + "version": "2.0.2", + "ref": "refs/heads/release/2.0", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": ["v2.0.0", "v2.0.1"], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-c-prerelease-from-next-line-releases", + "why": "Model C: an in-flight prerelease line releases 2.1.0-prerelease.1 from release/2.1, with the 2.0 line untouched.", + "version": "2.1.0-prerelease.1", + "ref": "refs/heads/release/2.1", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": ["v2.0.0", "v2.0.1", "v2.0.2"], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "model-c-stable-from-main-fails", + "why": "Model C reserves trunk: merging to main must not ship anything.", + "version": "2.1.0", + "ref": "refs/heads/main", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL003", "exitCode": 1 } + }, + { + "name": "model-c-patch-from-main-fails-rel004", + "why": "REL004: a non-zero PATCH is serviced from the line's release branch, never trunk. Reached only because the version is a prerelease, so REL003 does not short-circuit first.", + "version": "2.0.1-prerelease.1", + "ref": "refs/heads/main", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL004", "exitCode": 1 } + }, + { + "name": "model-c-version-regression-fails-rel005", + "why": "REL005: the version must advance past the highest existing version on its own line.", + "version": "2.0.1", + "ref": "refs/heads/release/2.0", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": ["v2.0.0", "v2.0.2"], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL005", "exitCode": 1 } + }, + { + "name": "model-c-other-line-does-not-block-rel005", + "why": "REL005 is scoped to the line, so a higher 2.1 version does not block a 2.0 patch.", + "version": "2.0.3", + "ref": "refs/heads/release/2.0", + "policy": "TrunkReservesMinor", + "strictness": "NuGet", + "existingTags": ["v2.0.2", "v2.1.0", "v2.1.1"], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "four-part-passes-under-nuget-strictness", + "why": "aspirec4 (13.5.3.10) and build-sdk (1.0.2.2) ship four-part versions today.", + "version": "13.5.3.10", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "four-part-fails-under-semver2-strictness", + "why": "REL001: SemVer2 strictness is the opt-in stricter setting that rejects four-part versions.", + "version": "13.5.3.10", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "SemVer2", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL001", "exitCode": 1 } + }, + { + "name": "four-part-fails-rel006-without-allowfourpart", + "why": "REL006 enabled by a policy that does not permit four-part versions.", + "version": "2.0.1.1", + "ref": "refs/heads/release/2.0", + "policy": "FourPartRejecting", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL006", "exitCode": 1 } + }, + { + "name": "four-part-passes-rel006-from-four-part-ref", + "why": "FourPartServicing permits four-part versions from a release branch.", + "version": "2.0.1.1", + "ref": "refs/heads/release/2.0", + "policy": "FourPartServicing", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Release", "ruleId": null, "exitCode": 0 } + }, + { + "name": "four-part-fails-rel006-from-wrong-ref", + "why": "REL006 also constrains WHERE a four-part version may be released from.", + "version": "2.0.1.1", + "ref": "refs/heads/main", + "policy": "FourPartServicingAnyStable", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL006", "exitCode": 1 } + }, + { + "name": "unparseable-version-fails-rel001", + "why": "The old VersionModule threw and the tool exited 1.", + "version": "not-a-version", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": null, + "expected": { "verdict": "Fail", "ruleId": "REL001", "exitCode": 1 } + }, + { + "name": "already-published-version-skips", + "why": "REL002's feed half, when a simulated published-version list is supplied.", + "version": "3.0.0", + "ref": "refs/heads/main", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": [], + "publishedVersions": ["2.9.0", "3.0.0"], + "expected": { "verdict": "Skip", "ruleId": "REL002", "exitCode": 0 } + }, + { + "name": "tag-skip-and-policy-fail-have-distinct-exit-codes", + "why": "A policy violation must not be mistakable for an already-released skip.", + "version": "4.0.0", + "ref": "refs/heads/feature/x", + "policy": "ReleaseOnMain", + "strictness": "NuGet", + "existingTags": ["v4.0.0"], + "publishedVersions": null, + "expected": { + "verdict": "Skip", + "ruleId": "REL002", + "exitCode": 0, + "whyThisOrder": "REL002 precedes REL003, so an already-released version skips before the ref is judged." + } + } + ], + "$policiesComment": [ + "Extra policies the scenarios above select, beyond the built-in ReleaseOnMain,", + "TrunkReservesMinor and FourPartServicing. Written into the generated purview-build.json", + "for the CLI matrix, and bound directly by the TUnit suite." + ], + "policies": { + "FourPartRejecting": { + "Inherits": "TrunkReservesMinor", + "Rules": ["+REL006"], + "AllowFourPart": false + }, + "FourPartServicingAnyStable": { + "Inherits": "FourPartServicing", + "StableRefs": ["refs/heads/main", "refs/heads/release/*"], + "ServicingRefs": ["refs/heads/main", "refs/heads/release/*"], + "FourPartRefs": ["refs/heads/release/*"] + } + } +} diff --git a/src/tests/fixtures/release-explain.golden.json b/src/tests/fixtures/release-explain.golden.json new file mode 100644 index 0000000..776fec2 --- /dev/null +++ b/src/tests/fixtures/release-explain.golden.json @@ -0,0 +1,127 @@ +{ + "verdict": "Release", + "exitCode": 0, + "releaseMode": "NuGet", + "message": "Every rule passed (REL001, REL002, REL003, REL004, REL005). The release is eligible.", + "decidedByRule": null, + "shortCircuitedAt": null, + "simulated": true, + "ref": "refs/heads/release/2.0", + "refSource": "Release:Context:Ref (simulated)", + "configuration": { + "resolvedPath": "", + "source": "Probe", + "probeTrail": [ + { + "relativePath": "purview-build.json", + "fullPath": "", + "exists": true + }, + { + "relativePath": ".config/purview-build.json", + "fullPath": "", + "exists": false + }, + { + "relativePath": ".build/purview-build.json", + "fullPath": "", + "exists": false + }, + { + "relativePath": "build/purview-build.json", + "fullPath": "", + "exists": false + }, + { + "relativePath": ".purview/purview-build.json", + "fullPath": "", + "exists": false + }, + { + "relativePath": ".github/purview-build.json", + "fullPath": "", + "exists": false + } + ], + "shadowedPaths": [], + "userConfigPath": null, + "userConfigActive": false + }, + "version": { + "raw": "2.0.2", + "source": "PackageJson", + "strictness": "NuGet", + "units": [ + { + "id": "2.0.2", + "version": "2.0.2", + "isPrerelease": false, + "prereleaseLabel": null, + "line": "2.0", + "channel": "stable", + "tag": "v2.0.2", + "source": "PackageJson" + } + ] + }, + "policy": { + "name": "TrunkReservesMinor", + "inheritanceChain": [ + "TrunkReservesMinor" + ], + "resolvedRules": [ + "REL001", + "REL002", + "REL003", + "REL004", + "REL005" + ], + "stableRefs": [ + "refs/heads/release/*" + ], + "servicingRefs": [ + "refs/heads/release/*" + ], + "trunkRefs": [ + "refs/heads/main" + ], + "fourPartRefs": [], + "allowFourPart": false + }, + "rules": [ + { + "id": "REL001", + "verdict": "pass", + "message": "REL001: Version '2.0.2' is valid for NuGet strictness." + }, + { + "id": "REL002", + "verdict": "pass", + "message": "REL002: v2.0.2 does not exist. The feed was not consulted, so only the tag was checked." + }, + { + "id": "REL003", + "verdict": "pass", + "message": "REL003: Stable version 2.0.2 is released from 'refs/heads/release/2.0'." + }, + { + "id": "REL004", + "verdict": "pass", + "message": "REL004: Servicing version 2.0.2 is released from 'refs/heads/release/2.0'." + }, + { + "id": "REL005", + "verdict": "pass", + "message": "REL005: Version 2.0.2 is greater than 2.0.1 on line 2.0." + } + ], + "publication": { + "mode": "NuGet", + "publish": true, + "githubRelease": true, + "channel": "stable", + "feedUrl": "https://api.nuget.org/v3/index.json", + "dryRun": false + }, + "warnings": [] +} diff --git a/src/tests/scenarios/find-tool.sh b/src/tests/scenarios/find-tool.sh new file mode 100644 index 0000000..2d43954 --- /dev/null +++ b/src/tests/scenarios/find-tool.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Locates the locally built purview-build binary and the appsettings.json that ships beside it. +# +# The scenario matrices run the real CLI, not `dotnet run`, so a 20-case sweep stays fast. The tool +# must therefore have been built first (`just build` or `just scenario-build`). Set +# PURVIEW_BUILD_TOOL to point at a different binary, for example an installed release. +set -eu + +# jq on Windows writes CRLF for raw output, and a stray CR ends up inside file names, paths and +# comparisons. Every scenario script reads jq through these wrappers instead of calling it directly. +jqr() { + jq -r "$@" | tr -d '\r' +} + +jqc() { + jq -c "$@" | tr -d '\r' +} + +if [ -z "${PURVIEW_BUILD_TOOL:-}" ]; then + for candidate in \ + "src/src/Build/bin/Release/net10.0/Purview.Build.exe" \ + "src/src/Build/bin/Release/net10.0/Purview.Build" \ + "src/src/Build/bin/Debug/net10.0/Purview.Build.exe" \ + "src/src/Build/bin/Debug/net10.0/Purview.Build"; do + if [ -x "$candidate" ]; then + PURVIEW_BUILD_TOOL="$candidate" + break + fi + done +fi + +if [ -z "${PURVIEW_BUILD_TOOL:-}" ]; then + echo "Could not find a built purview-build binary." >&2 + echo "Run 'just build' first, or set PURVIEW_BUILD_TOOL to a binary." >&2 + exit 1 +fi + +# Absolute, because every scenario runs with its working directory inside a throwaway repository. +TOOL=$(cd "$(dirname "$PURVIEW_BUILD_TOOL")" && pwd)/$(basename "$PURVIEW_BUILD_TOOL") + +# The shipped appsettings.json sits beside the binary. Pinning it explicitly keeps the scenarios +# independent of the compile-time source path the tool would otherwise fall back to, and exercises +# the documented separation between MODULAR_PIPELINES_DIRECTORY and config-file discovery. +MODULAR_PIPELINES_DIRECTORY=$(dirname "$TOOL") +export MODULAR_PIPELINES_DIRECTORY + +# A scenario must never pick up the developer's own environment. +unset PURVIEW_BUILD_CONFIG || true +unset PURVIEW_BUILD_USER_CONFIG || true diff --git a/src/tests/scenarios/run-config-matrix.sh b/src/tests/scenarios/run-config-matrix.sh new file mode 100644 index 0000000..c7a2a12 --- /dev/null +++ b/src/tests/scenarios/run-config-matrix.sh @@ -0,0 +1,214 @@ +#!/bin/sh +# Runs every case in src/tests/fixtures/config-resolution-scenarios.json through the real CLI and +# compares the resolved configuration path, the shadow warnings and the exit code against the file. +# +# Uses `release-explain --format=json`, which reports the whole resolution (resolved path, probe +# trail, shadowed files, user-config state) and runs no module, so nothing is built or mutated. +set -eu + +SCENARIOS="src/tests/fixtures/config-resolution-scenarios.json" +WORK=".scenario-runs/config" + +. "$(dirname "$0")/find-tool.sh" + +rm -rf "$WORK" +mkdir -p "$WORK" + +total=0 +failed=0 + +# Paths are compared by suffix, not by prefix-stripping an absolute root: the tool reports native +# paths (C:\... on Windows) while the shell's $PWD is a POSIX path (/p/...), so the two roots never +# match textually even when they denote the same directory. +ends_with() { + haystack=$(printf '%s' "$1" | tr '\\' '/') + needle=$(printf '%s' "$2" | tr '\\' '/') + + case "$haystack" in + *"/$needle") return 0 ;; + "$needle") return 0 ;; + *) return 1 ;; + esac +} + +count=$(jq '.scenarios | length' "$SCENARIOS" | tr -d '\r') +index=0 + +while [ "$index" -lt "$count" ]; do + scenario=$(jqc ".scenarios[$index]" "$SCENARIOS") + index=$((index + 1)) + total=$((total + 1)) + + name=$(printf '%s' "$scenario" | jqr '.name') + config=$(printf '%s' "$scenario" | jqr '.config // ""') + env_config=$(printf '%s' "$scenario" | jqr '.env // ""') + user_config=$(printf '%s' "$scenario" | jqr '.userConfig') + is_local=$(printf '%s' "$scenario" | jqr '.isLocal') + user_config_file=$(printf '%s' "$scenario" | jqr '.userConfigFile // false') + malformed=$(printf '%s' "$scenario" | jqr '.malformed // ""') + expected_path=$(printf '%s' "$scenario" | jqr '.expected.resolvedPath // ""') + expected_exit=$(printf '%s' "$scenario" | jqr '.expected.exitCode') + expected_error=$(printf '%s' "$scenario" | jqr '.expected.errorContains // ""') + expected_near_miss=$(printf '%s' "$scenario" | jqr '.expected.nearMissContains // ""') + expected_user_active=$(printf '%s' "$scenario" | jqr '.expected.userConfigActive // "null"') + + case_dir="$WORK/$name" + mkdir -p "$case_dir" + printf '{ "name": "scenario", "version": "1.0.0" }\n' > "$case_dir/package.json" + + # Every configuration file carries the same relative Build:Solution, so path anchoring is + # comparable wherever the file was found. + for file in $(printf '%s' "$scenario" | jqr '.files[]?'); do + mkdir -p "$case_dir/$(dirname "$file")" + if [ "$file" = "$malformed" ]; then + printf '{ "Build": { "Solution": "src/Product.slnx" \n' > "$case_dir/$file" + else + printf '{ "Build": { "Solution": "src/Product.slnx" }, "$marker": "%s" }\n' "$file" \ + > "$case_dir/$file" + fi + done + + user_home="$case_dir/.user-config-home" + mkdir -p "$user_home" + if [ "$user_config_file" = "true" ]; then + mkdir -p "$user_home/purview-build" + printf '{ "PublishLocalNuGet": { "LocalFeedPath": "/tmp/scenario-feed" } }\n' \ + > "$user_home/purview-build/purview-build.json" + fi + + # Absolute, so the run does not depend on the working directory it is launched from. + abs_case_dir=$(cd "$case_dir" && pwd) + abs_user_home=$(cd "$user_home" && pwd) + + cli_args="" + if [ -n "$config" ]; then + cli_args="--config $config" + fi + if [ "$user_config" = "true" ]; then + cli_args="$cli_args --user-config" + fi + + # Each case runs in its own subshell so the environment it needs cannot leak into the next. + set +e + out=$( + cd "$abs_case_dir" || exit 97 + + if [ -n "$env_config" ]; then + PURVIEW_BUILD_CONFIG="$env_config" + export PURVIEW_BUILD_CONFIG + fi + + XDG_CONFIG_HOME="$abs_user_home" + export XDG_CONFIG_HOME + + if [ "$is_local" = "true" ]; then + unset CI + unset GITHUB_ACTIONS + else + GITHUB_ACTIONS=true + export GITHUB_ACTIONS + fi + + # shellcheck disable=SC2086 + "$TOOL" release-explain --format=json $cli_args 2>&1 + ) + actual_exit=$? + set -e + + if [ "$expected_exit" -ne 0 ]; then + if [ "$actual_exit" -eq 0 ]; then + failed=$((failed + 1)) + printf 'FAIL %-52s expected a failure, got success\n' "$name" + elif [ -n "$expected_error" ] && ! printf '%s' "$out" | grep -qF "$expected_error"; then + failed=$((failed + 1)) + printf 'FAIL %-52s expected the error to mention "%s", got:\n%s\n' \ + "$name" "$expected_error" "$out" + else + printf 'ok %-52s failed as expected (%s)\n' "$name" "$expected_error" + fi + continue + fi + + if [ "$actual_exit" -ne 0 ]; then + failed=$((failed + 1)) + printf 'FAIL %-52s expected success, got exit %s:\n%s\n' "$name" "$actual_exit" "$out" + continue + fi + + actual_path=$(printf '%s' "$out" | jqr '.configuration.resolvedPath // ""') + actual_user_active=$(printf '%s' "$out" | jqr '.configuration.userConfigActive') + + case_failed=0 + + if [ -z "$expected_path" ]; then + if [ -n "$actual_path" ]; then + case_failed=1 + printf 'FAIL %-52s expected no configuration, got "%s"\n' "$name" "$actual_path" + fi + elif ! ends_with "$actual_path" "$expected_path"; then + case_failed=1 + printf 'FAIL %-52s expected resolved to end with "%s", got "%s"\n' \ + "$name" "$expected_path" "${actual_path:-none}" + fi + + expected_shadow_count=$(printf '%s' "$scenario" | jq '.expected.shadowWarnings | length' | tr -d '\r') + actual_shadow_count=$(printf '%s' "$out" | jq '.configuration.shadowedPaths | length' | tr -d '\r') + + if [ "$expected_shadow_count" != "$actual_shadow_count" ]; then + case_failed=1 + printf 'FAIL %-52s expected %s shadowed file(s), got %s\n' \ + "$name" "$expected_shadow_count" "$actual_shadow_count" + printf '%s' "$out" | jqr '.configuration.shadowedPaths[]? | " \(.)"' + else + shadow_index=0 + while [ "$shadow_index" -lt "$expected_shadow_count" ]; do + expected_shadow=$(printf '%s' "$scenario" \ + | jqr ".expected.shadowWarnings[$shadow_index]") + matched=0 + actual_shadow_index=0 + while [ "$actual_shadow_index" -lt "$actual_shadow_count" ]; do + actual_shadow=$(printf '%s' "$out" \ + | jqr ".configuration.shadowedPaths[$actual_shadow_index]") + if ends_with "$actual_shadow" "$expected_shadow"; then + matched=1 + fi + actual_shadow_index=$((actual_shadow_index + 1)) + done + + if [ "$matched" -eq 0 ]; then + case_failed=1 + printf 'FAIL %-52s expected "%s" to be reported as shadowed\n' "$name" "$expected_shadow" + fi + + shadow_index=$((shadow_index + 1)) + done + fi + + if [ -n "$expected_near_miss" ]; then + if ! printf '%s' "$out" | jqr '.warnings[]?' | grep -qF "$expected_near_miss"; then + case_failed=1 + printf 'FAIL %-52s expected a near-miss warning naming "%s"\n' "$name" "$expected_near_miss" + fi + fi + + if [ "$expected_user_active" != "null" ] \ + && [ "$actual_user_active" != "$expected_user_active" ]; then + case_failed=1 + printf 'FAIL %-52s expected userConfigActive=%s, got %s\n' \ + "$name" "$expected_user_active" "$actual_user_active" + fi + + if [ "$case_failed" -eq 0 ]; then + if [ -n "$expected_path" ]; then + printf 'ok %-52s resolved %s\n' "$name" "$expected_path" + else + printf 'ok %-52s no configuration (built-in defaults)\n' "$name" + fi + else + failed=$((failed + 1)) + fi +done + +printf '\n%s scenario(s), %s failed\n' "$total" "$failed" + +[ "$failed" -eq 0 ] || exit 1 diff --git a/src/tests/scenarios/run-eligibility-matrix.sh b/src/tests/scenarios/run-eligibility-matrix.sh new file mode 100644 index 0000000..e653d89 --- /dev/null +++ b/src/tests/scenarios/run-eligibility-matrix.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Runs every case in src/tests/fixtures/eligibility-scenarios.json through the real CLI and +# compares the verdict, deciding rule and exit code against the file. +# +# The same fixture drives the in-process TUnit suite, so a mismatch here means the shipped binary +# and the evaluator disagree. Needs no secrets and no network: every case supplies its own ref and +# tag list through Release:Context:*, which suppresses all process and network lookups. +set -eu + +SCENARIOS="src/tests/fixtures/eligibility-scenarios.json" +WORK=".scenario-runs/eligibility" + +. "$(dirname "$0")/find-tool.sh" + +rm -rf "$WORK" +mkdir -p "$WORK" + +total=0 +failed=0 + +# The extra policies the scenarios select, written into each generated purview-build.json. +POLICIES=$(jqc '.policies' "$SCENARIOS") + +count=$(jq '.scenarios | length' "$SCENARIOS" | tr -d '\r') +index=0 + +while [ "$index" -lt "$count" ]; do + scenario=$(jqc ".scenarios[$index]" "$SCENARIOS") + index=$((index + 1)) + total=$((total + 1)) + + name=$(printf '%s' "$scenario" | jqr '.name') + version=$(printf '%s' "$scenario" | jqr '.version') + gitref=$(printf '%s' "$scenario" | jqr '.ref') + policy=$(printf '%s' "$scenario" | jqr '.policy') + strictness=$(printf '%s' "$scenario" | jqr '.strictness') + expected_verdict=$(printf '%s' "$scenario" | jqr '.expected.verdict') + expected_rule=$(printf '%s' "$scenario" | jqr '.expected.ruleId // ""') + expected_exit=$(printf '%s' "$scenario" | jqr '.expected.exitCode') + + case_dir="$WORK/$name" + mkdir -p "$case_dir" + + # A throwaway repository root: the version under test, and the policies the case may select. + printf '{ "name": "scenario", "version": "%s" }\n' "$version" > "$case_dir/package.json" + printf '{ "Release": { "Eligibility": { "Policies": %s } } }\n' "$POLICIES" \ + > "$case_dir/purview-build.json" + + printf '%s' "$scenario" | jqr '.existingTags[]?' > "$case_dir/tags.txt" + + set -- \ + "--Release:Context:Ref=$gitref" \ + "--Release:Context:ExistingTags=tags.txt" \ + "--Release:Eligibility:Policy=$policy" \ + "--Version:Strictness=$strictness" + + if [ "$(printf '%s' "$scenario" | jqr '.publishedVersions // "null"')" != "null" ]; then + printf '%s' "$scenario" | jqr '.publishedVersions[]' > "$case_dir/published.txt" + set -- "$@" "--Release:Context:PublishedVersions=published.txt" + fi + + if ! out=$(cd "$case_dir" && "$TOOL" release-explain --format=json "$@" 2>&1); then + printf 'FAIL %-52s the CLI exited non-zero:\n%s\n' "$name" "$out" + failed=$((failed + 1)) + continue + fi + + actual_verdict=$(printf '%s' "$out" | jqr '.verdict') + actual_rule=$(printf '%s' "$out" | jqr '.decidedByRule // ""') + actual_exit=$(printf '%s' "$out" | jqr '.exitCode') + + if [ "$actual_verdict" = "$expected_verdict" ] \ + && [ "$actual_rule" = "$expected_rule" ] \ + && [ "$actual_exit" = "$expected_exit" ]; then + if [ -n "$actual_rule" ]; then + printf 'ok %-52s %s via %s (exit %s)\n' "$name" "$actual_verdict" "$actual_rule" "$actual_exit" + else + printf 'ok %-52s %s (exit %s)\n' "$name" "$actual_verdict" "$actual_exit" + fi + else + failed=$((failed + 1)) + printf 'FAIL %-52s expected %s/%s/exit %s, got %s/%s/exit %s\n' \ + "$name" "$expected_verdict" "${expected_rule:-none}" "$expected_exit" \ + "$actual_verdict" "${actual_rule:-none}" "$actual_exit" + printf '%s' "$out" | jqr '.rules[] | " \(.verdict) \(.message)"' + fi +done + +printf '\n%s scenario(s), %s failed\n' "$total" "$failed" + +[ "$failed" -eq 0 ] || exit 1 diff --git a/src/tests/scenarios/simulate.sh b/src/tests/scenarios/simulate.sh new file mode 100644 index 0000000..45e56de --- /dev/null +++ b/src/tests/scenarios/simulate.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Explains the release decision for a simulated ref and version, in a throwaway repository root so +# the working tree's own package.json is untouched. +# +# Mutates nothing and makes no network calls: supplying Release:Context:* suppresses both the git +# lookups and any feed query. +set -eu + +if [ "$#" -lt 2 ]; then + echo "usage: simulate.sh [extra purview-build arguments]" >&2 + echo "example: simulate.sh refs/heads/release/2.0 2.0.2 --Release:Eligibility:Policy=TrunkReservesMinor" >&2 + exit 2 +fi + +REF="$1" +VERSION="$2" +shift 2 + +. "$(dirname "$0")/find-tool.sh" + +WORK=".scenario-runs/simulate" +rm -rf "$WORK" +mkdir -p "$WORK" + +printf '{ "name": "simulated", "version": "%s" }\n' "$VERSION" > "$WORK/package.json" + +# Carry over the real repository's configuration, so a simulation reflects how this repository is +# actually configured rather than bare defaults. +if [ -f "purview-build.json" ]; then + cp "purview-build.json" "$WORK/purview-build.json" +fi + +# The tags that really exist, so "already released" is judged against reality. +if git rev-parse --git-dir >/dev/null 2>&1; then + git tag --list > "$WORK/tags.txt" +else + : > "$WORK/tags.txt" +fi + +cd "$WORK" + +exec "$TOOL" release-explain \ + "--Release:Context:Ref=$REF" \ + "--Release:Context:ExistingTags=tags.txt" \ + "$@"