diff --git a/.github/actions/purview-build/action.yml b/.github/actions/purview-build/action.yml new file mode 100644 index 0000000..697618e --- /dev/null +++ b/.github/actions/purview-build/action.yml @@ -0,0 +1,48 @@ +name: Purview Build + +description: >- + Installs the pinned Purview.Build dotnet tool from the Purview-Dev GitHub Packages feed and runs + it against the repository. Configuration flows through job environment variables (Build__*, + Release__*, NuGet__*, PublishLocalNuGet__*) and secrets (NUGET_APIKEY, GITHUB_TOKEN, + LOCAL_NUGET_FEED_PATH). + +inputs: + build-version: + description: Exact Purview.Build package version to run. + required: true + dotnet-version: + description: .NET SDK used by the consuming repository. + required: false + default: 10.0.x + +runs: + using: composite + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ inputs.dotnet-version }} + + - name: Authenticate to Purview-Dev packages + shell: bash + run: >- + dotnet nuget add source + "https://nuget.pkg.github.com/purview-dev/index.json" + --name purview-dev + --username "${{ github.actor }}" + --password "${{ github.token }}" + --store-password-in-clear-text + + - name: Install pinned shared build + shell: bash + run: >- + dotnet tool install Purview.Build + --tool-path "${{ runner.temp }}/purview-build" + --source "https://nuget.pkg.github.com/purview-dev/index.json" + --version "${{ inputs.build-version }}" + + - name: Run shared build + shell: bash + run: "${{ runner.temp }}/purview-build/purview-build" + env: + GITHUB_TOKEN: ${{ github.token }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0edd63..89b9745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,19 +17,27 @@ jobs: run: dotnet restore src/Purview.Build/Purview.Build.csproj --locked-mode - name: Build gate run: dotnet build src/Purview.Build/Purview.Build.csproj --configuration Release --no-restore --warnaserror - - name: Pack gate - run: dotnet pack src/Purview.Build/Purview.Build.csproj --configuration Release --no-build --output artifacts - - name: Install packed tool + - name: Read version + id: version shell: bash run: | - VERSION=$(dotnet msbuild src/Purview.Build/Purview.Build.csproj -getProperty:Version -nologo) - dotnet tool install Purview.Build --tool-path "$RUNNER_TEMP/purview-build" --add-source artifacts --version "$VERSION" - echo "$RUNNER_TEMP/purview-build" >> "$GITHUB_PATH" - - name: Installed-tool smoke gate + VERSION=$(node -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 + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Pack tool run: >- - purview-build - --Build:Solution=src/Purview.Build/Purview.Build.csproj - --Build:VersionFile=testassets/version.json - --Build:Lint=false - --Build:Test=false - --Build:Pack=false + dotnet pack src/Purview.Build/Purview.Build.csproj + --configuration Release --no-build --output artifacts + -p:Version=${{ steps.version.outputs.version }} + -p:PackageVersion=${{ steps.version.outputs.version }} + - name: Install packed tool + shell: bash + run: | + dotnet tool install Purview.Build --tool-path "$RUNNER_TEMP/purview-build" --add-source artifacts --version "${{ steps.version.outputs.version }}" + - name: Dogfood — the project builds itself + env: + GITHUB_TOKEN: ${{ github.token }} + run: "$RUNNER_TEMP/purview-build/purview-build" \ No newline at end of file diff --git a/.github/workflows/purview-build.yml b/.github/workflows/purview-build.yml index 1bb6cc5..8856be7 100644 --- a/.github/workflows/purview-build.yml +++ b/.github/workflows/purview-build.yml @@ -4,24 +4,60 @@ on: workflow_call: inputs: build-version: - description: Exact Purview.Build package version to run + description: Exact Purview.Build package version to run. required: true type: string dotnet-version: - description: .NET SDK used by the consuming repository + description: .NET SDK used by the consuming repository. required: false default: 10.0.x type: string + release-mode: + description: Release__Mode passed to the pipeline (None, NuGet, GitHubRelease, LocalNuGet). + required: false + default: None + type: string + run-tests: + description: Enable discovered tests (Build__RunTests). + required: false + default: true + type: boolean + run-lint: + description: Enable CSharpier linting (Build__RunLint). + required: false + default: true + type: boolean + run-pack: + description: Enable packing (Build__RunPack). + required: false + default: true + type: boolean + validate-pack: + description: Enable pack validation (Build__ValidatePack). + required: false + default: true + type: boolean + test-filter: + description: Test filter (Build__TestFilter), e.g. "/*/*/*/*[Category=Unit]". + required: false + type: string + test-projects: + description: Comma-separated test project names/globs to run (Build__TestProjects). + required: false + type: string secrets: - NUGET_API_KEY: + NUGET_APIKEY: required: false +permissions: + contents: write + packages: read + jobs: build: + name: Build runs-on: ubuntu-latest - permissions: - contents: write - packages: read + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: @@ -51,5 +87,15 @@ jobs: - name: Run shared build env: GITHUB_TOKEN: ${{ github.token }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - run: "${{ runner.temp }}/purview-build/purview-build" + # 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 }} + Build__RunTests: ${{ inputs.run-tests }} + Build__RunLint: ${{ inputs.run-lint }} + Build__RunPack: ${{ inputs.run-pack }} + Build__ValidatePack: ${{ inputs.validate-pack }} + Build__TestFilter: ${{ inputs.test-filter }} + Build__TestProjects: ${{ inputs.test-projects }} + run: "${{ runner.temp }}/purview-build/purview-build" \ No newline at end of file diff --git a/.github/workflows/purview-release.yml b/.github/workflows/purview-release.yml new file mode 100644 index 0000000..3a7deec --- /dev/null +++ b/.github/workflows/purview-release.yml @@ -0,0 +1,137 @@ +name: Purview Release + +on: + workflow_call: + inputs: + build-version: + description: Exact Purview.Build package version to run. + required: true + type: string + dotnet-version: + description: .NET SDK used by the consuming repository. + required: false + default: 10.0.x + type: string + release-mode: + description: Release__Mode passed to the pipeline (NuGet or GitHubRelease). + required: false + default: NuGet + type: string + release-branch: + description: >- + Branch that triggers this release (main or release). Used only for the concurrency group. + required: false + default: main + type: string + trusted-publishing: + description: Use NuGet Trusted Publishing (no API key) instead of an API key. + required: false + default: false + type: boolean + run-tests: + description: Enable discovered tests (Build__RunTests). + required: false + default: true + type: boolean + run-lint: + description: Enable CSharpier linting (Build__RunLint). + required: false + default: true + type: boolean + run-pack: + description: Enable packing (Build__RunPack). + required: false + default: true + type: boolean + validate-pack: + description: Enable pack validation (Build__ValidatePack). + required: false + default: true + type: boolean + test-filter: + description: Test filter (Build__TestFilter), e.g. "/*/*/*/*[Category=Unit]". + required: false + type: string + upload-artifacts: + description: Upload artifacts folder files as GitHub release assets (Release__UploadArtifacts). + required: false + default: false + type: boolean + secrets: + NUGET_APIKEY: + required: false + +permissions: + contents: write + packages: read + +concurrency: + group: purview-release-${{ inputs.release-branch }} + cancel-in-progress: false + +jobs: + release: + name: Release + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ inputs.dotnet-version }} + + - name: Check for version bump + id: version + shell: bash + run: | + VERSION=$(node -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" + fi + + - name: Authenticate to Purview-Dev packages + if: steps.version.outputs.should_release == 'true' + run: >- + dotnet nuget add source + "https://nuget.pkg.github.com/purview-dev/index.json" + --name purview-dev + --username "${{ github.actor }}" + --password "${{ github.token }}" + --store-password-in-clear-text + + - name: Install pinned shared build + if: steps.version.outputs.should_release == 'true' + run: >- + dotnet tool install Purview.Build + --tool-path "${{ runner.temp }}/purview-build" + --source "https://nuget.pkg.github.com/purview-dev/index.json" + --version "${{ inputs.build-version }}" + + - name: Run release pipeline + if: steps.version.outputs.should_release == 'true' + 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 }} + Build__TestFilter: ${{ inputs.test-filter }} + run: "${{ runner.temp }}/purview-build/purview-build" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c865ec9..70403f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,11 @@ name: Release -# A successful CI run on main releases the version declared by the project. The -# workflow creates the matching tag; maintainers never create release tags by hand. +# The project builds itself: this workflow compiles and installs the tool from the +# current source, then runs it against this repository. The tool therefore performs +# the release build/test/pack/validate steps and tags and publishes itself (v{version} +# release + NuGet push) exactly like every other purview-dev repository. on: - workflow_run: - workflows: [CI] - types: [completed] + push: branches: [main] permissions: @@ -18,12 +18,11 @@ concurrency: jobs: release: - if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 fetch-tags: true @@ -35,9 +34,9 @@ jobs: id: version shell: bash run: | - VERSION=$(dotnet msbuild src/Purview.Build/Purview.Build.csproj -getProperty:Version -nologo) + VERSION=$(node -p "require('./package.json').version") if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Project Version '$VERSION' is not SemVer." >&2 + echo "package.json version '$VERSION' is not SemVer." >&2 exit 1 fi TAG="v$VERSION" @@ -50,35 +49,40 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Pack release + - name: Restore + if: steps.version.outputs.release == 'true' + run: dotnet restore src/Purview.Build/Purview.Build.csproj --locked-mode + + - name: Build gate + if: steps.version.outputs.release == 'true' + run: dotnet build src/Purview.Build/Purview.Build.csproj --configuration Release --no-restore --warnaserror + + - name: Pack tool from source if: steps.version.outputs.release == 'true' run: >- dotnet pack src/Purview.Build/Purview.Build.csproj - --configuration Release - --output artifacts + --configuration Release --no-build --output artifacts -p:ContinuousIntegrationBuild=true + -p:Version=${{ steps.version.outputs.version }} + -p:PackageVersion=${{ steps.version.outputs.version }} - - name: Verify package version - if: steps.version.outputs.release == 'true' - shell: bash - run: test -f "artifacts/Purview.Build.${{ steps.version.outputs.version }}.nupkg" - - - name: Publish to Purview-Dev internal NuGet registry + - name: Install packed tool if: steps.version.outputs.release == 'true' run: >- - dotnet nuget push - "artifacts/Purview.Build.${{ steps.version.outputs.version }}.nupkg" - --source "https://nuget.pkg.github.com/purview-dev/index.json" - --api-key "${{ github.token }}" - --skip-duplicate + dotnet tool install Purview.Build + --tool-path "$RUNNER_TEMP/purview-build" + --add-source artifacts + --version "${{ steps.version.outputs.version }}" - - name: Tag released version and create release + - name: Run release pipeline (builds, publishes and tags itself) if: steps.version.outputs.release == 'true' env: - GH_TOKEN: ${{ github.token }} - run: >- - gh release create "${{ steps.version.outputs.tag }}" - "artifacts/Purview.Build.${{ steps.version.outputs.version }}.nupkg" - --target "${{ github.event.workflow_run.head_sha }}" - --title "${{ steps.version.outputs.tag }}" - --generate-notes + GITHUB_TOKEN: ${{ github.token }} + Release__Mode: NuGet + Release__UploadArtifacts: "true" + NuGet__FeedUrl: https://nuget.pkg.github.com/purview-dev/index.json + NuGet__ApiKey: ${{ github.token }} + Build__RunTests: "false" + Build__RunLint: "false" + Build__ValidatePack: "false" + run: "$RUNNER_TEMP/purview-build/purview-build" \ No newline at end of file diff --git a/README.md b/README.md index 8cc7ad4..1e71726 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,123 @@ # Purview.Build -`Purview.Build` is the shared build CLI for the `purview-dev` organisation. It packages the existing Modular Pipelines implementation as a pinned .NET tool: consuming repositories own configuration, but not pipeline source code. +`Purview.Build` is the shared build/test/release system for the `purview-dev` organisation. It is a Generalized Modular Pipelines pipeline (based on the `PipelineCLI` originally developed in `sourcegeneratorframework`) packaged as a pinned .NET tool and exposed through a shared GitHub composite action and thin reusable workflows. -## Minimal repository setup +Consuming repositories own configuration; they do not own pipeline source code. Version, paths, feature switches, and release-mode selection are per-repository. -The recommended GitHub Actions setup is a single reusable-workflow job. Pin both the workflow ref and package version: +## Delivery surfaces + +The same implementation is available three ways: + +1. **`Purview.Build` dotnet tool** — NuGet package published to the Purview-Dev GitHub Packages feed. Run anywhere a .NET SDK exists (locally via `just`, in GitHub Actions, or another CI service). +2. **Composite action** `purview-dev/build/.github/actions/purview-build` — for repositories that want the action embedded directly in one of their own jobs. +3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. + +## Minimal repository setup (reusable workflow) ```yaml -name: Build -on: [pull_request, push] -permissions: - contents: write - packages: read +# .github/workflows/pr.yml +name: PR +on: + pull_request: + branches: [main] jobs: build: - uses: purview-dev/build/.github/workflows/purview-build.yml@v0.1.0 + uses: purview-dev/build/.github/workflows/purview-build.yml@v0.2.0 with: - build-version: 0.1.0 + build-version: 0.2.0 secrets: inherit ``` -The reusable workflow authenticates to the internal Purview-Dev NuGet registry, installs the exact CLI version, and runs it. The consuming repository only adds `purview-build.json`; it does not need a copied pipeline project, package-source credentials, or setup steps. +```yaml +# .github/workflows/release.yml — release on main +name: Release +on: + push: + branches: [main] +jobs: + release: + uses: purview-dev/build/.github/workflows/purview-release.yml@v0.2.0 + with: + build-version: 0.2.0 + release-mode: NuGet + secrets: inherit +``` + +For the **main-as-head / release-branch model**, point the release caller at the release branch instead: + +```yaml +on: + push: + branches: [release] +``` + +The reusable 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 local use, authenticate once to the organization feed with a classic PAT carrying `read:packages`, then install a released version into the repository: +The reusable workflows authenticate to the internal feed and install the exact CLI version; 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. + +### Minimal repository setup (composite action) + +```yaml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: purview-dev/build/.github/actions/purview-build@v0.2.0 + with: + build-version: 0.2.0 + env: + Build__TestFilter: "/*/*/*/*[Category=Unit]" +``` + +### Local use ```shell -dotnet new tool-manifest -dotnet tool install Purview.Build --version 0.1.0 --add-source https://nuget.pkg.github.com/purview-dev/index.json -dotnet tool restore -dotnet purview-build +dotnet tool install Purview.Build --tool-path ./.tools --add-source https://nuget.pkg.github.com/purview-dev/index.json --version 0.2.0 +./.tools/purview-build ``` -Add `purview-build.json` at the repository root: +## 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`. ```json { "Build": { "Solution": "src/MyProduct.slnx", "TestRoot": "src/tests", - "TestPatterns": ["*Tests.csproj"], - "TestFilter": "/*/*/*/*[Category=Unit]", - "PackTarget": "src/MyProduct.slnx" + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" + }, + "PackValidation": { + "RequireSymbolPackage": true, + "RequiredContent": { + "my.product": ["lib/netstandard2.0/My.Product.dll"] + } }, "Release": { "Mode": "None" } } ``` -Configuration precedence is command line, environment variables, `purview-build.json`, then defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. Secrets should only be supplied through `NUGET_API_KEY` and `GITHUB_TOKEN`. +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`). -## Pipeline +See [architecture and configuration](docs/architecture.md) and [release strategy](docs/releasing.md). -The tool runs these Modular Pipelines modules and dependencies: +## Pipeline ```text Version ───────────────┐ -Restore → Build → Test ├→ Pack → Publish → GitHub release +Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release └→ Lint │ Version ───────────────┘ ``` -`Version` reads the SemVer `version` field from `package.json` by default. Lint restores the repository's local tools and runs CSharpier. Tests are discovered rather than hard-coded. Pack, publication, and GitHub release steps are independently controlled by configuration and release mode. - -See [architecture and configuration](docs/architecture.md), [release strategy](docs/releasing.md), and the [aspire-resourcekit migration](docs/migrations/aspire-resourcekit.md). +`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. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. ## Repository CI/CD -This repository gates every pull request with locked restore, warnings-as-errors compilation, package creation, installation of the generated package, and an end-to-end CLI smoke run. A successful CI run on `main` triggers 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. Locked restore and warnings-as-errors compilation gate every pull request and merge. -CD reads the project `Version`, publishes that immutable package to `https://nuget.pkg.github.com/purview-dev/index.json`, then creates the matching `v{Version}` tag and GitHub release. Maintainers bump the project 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, then runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` pointing at the Purview-Dev GitHub Packages registry, and `Release__UploadArtifacts=true`. The tool therefore publishes the immutable package to `https://nuget.pkg.github.com/purview-dev/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. -After the first publication, an organization owner must set `Purview.Build` to **Internal** under Purview-Dev → Packages → Purview.Build → Package settings. GitHub initially creates NuGet packages as private. Also enable internal package creation under the organization's package settings if it is disabled. +After the first publication, an organization owner must set `Purview.Build` to **Internal** under Purview-Dev → Packages → Purview.Build → Package settings. GitHub initially creates NuGet packages as private. Also enable internal package creation under the organization's package settings if it is disabled. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index ce4c639..3df5293 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,9 +4,14 @@ The shared artifact is a .NET tool NuGet package, not a reusable workflow and not an MSBuild SDK. Modular Pipelines is an executable orchestration system, so a tool is its natural package boundary. A tool manifest gives each consumer deterministic version pinning and Renovate/Dependabot-compatible upgrades. It also keeps GitHub Actions as a thin host; the same command runs locally, in GitHub Actions, or in another CI service. -An MSBuild SDK remains a possible future companion for shared compile-time properties, analyzers, or package metadata. It should not own CI orchestration. +The implementation is the generalized `PipelineCLI` that originated in `sourcegeneratorframework` (its most advanced version, including pack validation). It supersedes the earlier `Purview.Build` modules. + +The repository additionally exposes: -The repository also exposes a thin reusable GitHub workflow. It contains no build policy: it authenticates to the organization feed, installs an exact `Purview.Build` version, and invokes the package. This reduces a consumer to one reusable-workflow job plus `purview-build.json` while the NuGet package remains the portable implementation boundary. +- a **composite action** (`.github/actions/purview-build`) that installs a pinned `Purview.Build` version and runs it, for repositories embedding the build in their own jobs, and +- two **reusable workflows** (`purview-build.yml`, `purview-release.yml`) that wrap that logic with structured inputs/secrets, reducing a consumer to one reusable-workflow job plus `purview-build.json`. + +An MSBuild SDK remains a possible future companion for shared compile-time properties, analyzers, or package metadata. It should not own CI orchestration. ## Ownership boundary @@ -14,40 +19,87 @@ The package owns module implementation, dependency ordering, safe defaults, secr ## Configuration reference +### `Build` + | Key | Default | Purpose | |---|---|---| -| `Build:Solution` | `src` | Solution, project, or directory passed to restore/build | -| `Build:Configuration` | `Release` | .NET configuration | -| `Build:ArtifactsDirectory` | `artifacts` | Package output directory | -| `Build:Lint` | `true` | Restore local tools and run CSharpier check | -| `Build:Test` | `true` | Enable discovered tests | -| `Build:TestRoot` | `src/tests` | Test discovery root | -| `Build:TestPatterns` | `[*Tests.csproj]` | Recursive project search patterns; supports separate unit/integration naming | -| `Build:TestFilter` | `/*/*/*/*/` | TUnit tree-node filter; empty disables it | -| `Build:TestArguments` | `--ignore-exit-code 8` | Additional arguments after `dotnet test --` | -| `Build:Pack` | `true` | Enable packing | -| `Build:PackTarget` | `src` | Solution/project/directory to pack | -| `Build:VersionFile` | `package.json` | JSON file containing a SemVer `version` | -| `Release:Mode` | `None` | `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease` | -| `Release:NuGetFeed` | nuget.org v3 | Remote package source | -| `Release:LocalFeed` | unset | Absolute local package source | -| `Release:CreateGitHubRelease` | `true` | Create a generated-notes release after NuGet publication | - -`Release:NuGetApiKey` and `Release:GitHubToken` exist for configuration binding, but committed JSON must not contain them. Use `NUGET_API_KEY` and `GITHUB_TOKEN`. +| `Solution` | `src/Product.slnx` | Solution, project, or directory passed to restore/build/pack | +| `Configuration` | `Release` | .NET configuration | +| `ArtifactsFolder` | `artifacts` | Package output directory | +| `RunTests` | `true` | Enable discovered tests | +| `TestRoot` | `src/tests` | Test discovery root (relative to the repository root) | +| `TestPatterns` | `*Tests.csproj` | Comma-separated project search patterns applied under `TestRoot` | +| `TestProjects` | `*` | Comma-separated project names/globs to run; `*` runs all discovered | +| `TestFramework` | `TUnit` | `TUnit` (tree-node filter) or `xUnit` (VSTest filter) | +| `TestFilter` | `/*/*/*/*/` | TUnit tree-node filter or xUnit `--filter`; empty disables it | +| `RunLint` | `true` | Restore local tools and run CSharpier check | +| `RunPack` | `true` | Enable packing | +| `ValidatePack` | `true` | Enable pack validation | -Command-line overrides use configuration syntax, for example: +### `PackValidation` -```shell -dotnet purview-build --Build:TestPatterns:0=*IntegrationTests.csproj --Build:Pack=false -``` +| Key | Default | Purpose | +|---|---|---| +| `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa | +| `RequireSymbolFiles` | `true` | Every `.snupkg` must contain at least one `.pdb` | +| `RequiredContent` | `{}` | Package id → entry paths that must be present in the `.nupkg` | +| `ForbiddenContent` | `{}` | Package id → entry paths that must not be present in the `.nupkg` | + +### `NuGet` + +| Key | Default | Purpose | +|---|---|---| +| `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` | +| `EnvAPIKey` | unset | Binds `NuGet__NUGET_APIKEY`; also falls back to process env `NUGET_APIKEY`/`NUGET_API_KEY` | + +### `PublishLocalNuGet` + +| Key | Default | Purpose | +|---|---|---| +| `LocalFeedPath` | unset | Absolute local package source | +| `EnvLocalFeedPath` | unset | Binds `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH`; also falls back to process env `LOCAL_NUGET_FEED_PATH` | +| `OverwriteExistingPackages` | `true` | Overwrite packages already in the local feed | +| `ShutdownDotnetBuilderServer` | `true` | Shut down the dotnet build server after publishing | +| `ClearPackageCache` | `true` | Clear the local NuGet package caches for the published packages | + +### `GitHub` + +| Key | Default | Purpose | +|---|---|---| +| `AccessToken` | unset | Secret; use `GITHUB_TOKEN` | +| `EnvAccessToken` | unset | Binds `GitHub__GITHUB_TOKEN`; also falls back to process env `GITHUB_TOKEN` | +| `ProductHeader` | `Purview.Build.Pipeline` | GitHub API product header | + +### `Release` + +| Key | Default | Purpose | +|---|---|---| +| `Mode` | `None` | `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease` | +| `UploadArtifacts` | `false` | Upload every file in `Build:ArtifactsFolder` as GitHub release assets | + +## Project, testing, and release support -Arrays use indexed keys in environment variables (`Build__TestArguments__0=--coverage`). For substantially different test types, select projects with the pattern/root and supply runner arguments; separate invocations may use different override sets. +- **Project types**: the pipeline is dotnet-first (libraries, source generators, analyzers, MSBuild SDKs, Aspire hosting extensions). Non-dotnet project types (`Web` for full-stack apps, `WebExtension` for JS/Azure DevOps extensions) are designed as future module additions gated by configuration. +- **Testing types**: TUnit on Microsoft.Testing.Platform (default) and xUnit, both configurable via `TestFramework`/`TestFilter`. Non-dotnet runners (Vitest, Playwright, Jest, Astro) are future modules. +- **Release types**: nuget.org (API key or Trusted Publishing), GitHub Packages internal feed, local NuGet feed, GitHub release (optionally with package/vsix assets), and future Aspire-deploy / Azure DevOps marketplace publishing. ## Release behavior - `None`: build/test/pack may run, but nothing publishes. -- `LocalNuGet`: pushes packages to `Release:LocalFeed` for developer testing. +- `LocalNuGet`: pushes packages to the resolved local feed for developer testing. - `NuGet`: pushes packages to the configured feed and, by default, creates a GitHub release. -- `GitHubRelease`: creates a GitHub release without publishing NuGet packages. +- `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. + +## Repository root resolution -The workflow should decide whether a version is eligible to release (for example, only an untagged version on `main`) and set `Release__Mode`. Credentials remain CI secrets. +The tool locates the repository root by walking up from the current working directory to the nearest `package.json`; `Environment.CurrentDirectory` is set to that root before modules run. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json` when the defaults do not apply. + +Command-line overrides use configuration syntax, for example: + +```shell +dotnet purview-build --Build:TestPatterns=*IntegrationTests.csproj --Build:RunPack=false +``` \ No newline at end of file diff --git a/docs/migrations/aspire-resourcekit.md b/docs/migrations/aspire-resourcekit.md index 92c5425..9996b4d 100644 --- a/docs/migrations/aspire-resourcekit.md +++ b/docs/migrations/aspire-resourcekit.md @@ -1,26 +1,24 @@ # Migration: aspire-resourcekit -This repository currently contains the representative copied implementation at `build/PipelineCLI`. Migrate it as follows. +This repository currently contains a vendored copy of `build/PipelineCLI`. Migrate it as follows. -1. Add and commit a tool manifest with `Purview.Build` pinned to the chosen released version. +1. Replace the vendored `build/PipelineCLI` with the shared `Purview.Build` tool pinned to the chosen released version. 2. Add this `purview-build.json`: ```json { "Build": { "Solution": "src/ResourceKit.slnx", - "ArtifactsDirectory": "artifacts", "TestRoot": "src/tests", - "TestPatterns": ["*Tests.csproj"], - "TestFilter": "/*/*/*/*[Category=Unit]", - "PackTarget": "src/ResourceKit.slnx" + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" }, "Release": { "Mode": "None" } } ``` -3. Replace `dotnet run --project build/PipelineCLI/PipelineCLI.csproj --configuration Release` in both workflows with `dotnet tool restore` followed by `dotnet purview-build`. -4. In the release job set `Release__Mode=NuGet`, `NUGET_API_KEY`, and `GITHUB_TOKEN`. Keep the existing untagged-version guard until it is generalized here. -5. Run the PR pipeline, then delete `build/PipelineCLI` and its pipeline-only central package declarations. +3. Replace `pr.yml` and `release.yml` with thin callers of `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml`, passing `build-version`. Keep the `[Category=Unit]` filter in the release caller. +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`. +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 diff --git a/docs/releasing.md b/docs/releasing.md index f0b22a9..9cadc33 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,18 +6,37 @@ - Minor: additive options or modules with backward-compatible defaults. - Major: renamed/removed keys, changed defaults with material effects, or a required runtime upgrade. -Consumers pin an exact version in `.config/dotnet-tools.json`; never use a floating range. Automated dependency updates should open a pull request, where the consumer's normal build validates the new tool before merge. Keep the previous major supported while migrations are in progress. +Consumers pin an exact version in the reusable-workflow `build-version` input (and, for local use, `.config/dotnet-tools.json`); never use a floating range. Automated dependency updates should open a pull request, where the consumer's normal build validates the new tool before merge. Keep the previous major supported while migrations are in progress. -The version is declared by the `Version` property in `src/Purview.Build/Purview.Build.csproj`. Releasing consists of bumping that property and merging the validated pull request into `main`. +The version is declared by the `version` field in the repository's root `package.json`. Releasing consists of bumping that field and merging the validated pull request into the release head. -CI performs locked restore, warnings-as-errors compilation, packing, installation from the generated package, and an end-to-end CLI smoke run. Only after that workflow succeeds on `main` does CD run. CD reads and validates the project version, skips it when `v{version}` already exists, publishes the immutable package to the Purview-Dev GitHub Packages NuGet registry, and creates `v{version}` plus a generated-notes GitHub release against the exact validated commit. The workflow therefore owns tagging; maintainers must not push release tags manually. +## 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: + +- **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]`. + +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. + +## This repository's CI/CD + +This repository dogfoods the shared tool. CI performs locked restore, warnings-as-errors compilation, packing, installation from the generated package, then runs `purview-build` against this repository so the project builds and packs itself. + +On a push to `main`, the release workflow reads and validates the `package.json` version, skips when `v{version}` already exists, then builds and installs the tool from the current source and runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` set to the Purview-Dev GitHub Packages registry, and `Release__UploadArtifacts=true`. The tool performs the release build/pack steps, publishes the immutable package to the registry, and creates `v{version}` plus a generated-notes GitHub release with the package attached — tagging itself exactly like every other purview-dev repository. The tool therefore owns tagging; maintainers must not push release tags manually. GitHub creates the package as private on its first publication. An organization owner must make the package Internal once in the package settings so Purview-Dev members can consume it, and must permit internal package creation in the organization's package policy. NuGet versions are immutable; `--skip-duplicate` makes recovery safe if publication succeeded but tagging was interrupted. -For local validation: +## For local validation ```shell -dotnet pack src/Purview.Build/Purview.Build.csproj -c Release -o artifacts -dotnet tool install Purview.Build --tool-path ./.tools --add-source ./artifacts --version 0.1.0 +dotnet pack src/Purview.Build/Purview.Build.csproj -c Release -o artifacts -p:Version=0.2.0 -p:PackageVersion=0.2.0 +dotnet tool install Purview.Build --tool-path ./.tools --add-source ./artifacts --version 0.2.0 ./.tools/purview-build ``` + +To publish packages built by a consumer to a local feed for development: + +```shell +LOCAL_NUGET_FEED_PATH=p:/_sync-projects/.local-nuget/ ./.tools/purview-build --Release:Mode=LocalNuGet +``` \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..03e9d75 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "purview-build", + "version": "0.2.0", + "private": true +} \ No newline at end of file diff --git a/purview-build.json b/purview-build.json new file mode 100644 index 0000000..8d00e1d --- /dev/null +++ b/purview-build.json @@ -0,0 +1,14 @@ +{ + "Build": { + "Solution": "src/Purview.Build/Purview.Build.csproj", + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "RunTests": false, + "RunLint": false, + "RunPack": true, + "ValidatePack": false + }, + "Release": { + "Mode": "None" + } +} \ No newline at end of file diff --git a/src/Purview.Build/BuildOptions.cs b/src/Purview.Build/BuildOptions.cs deleted file mode 100644 index 674a123..0000000 --- a/src/Purview.Build/BuildOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Purview.Build; - -public sealed class BuildOptions -{ - public string Solution { get; init; } = "src"; - public string Configuration { get; init; } = "Release"; - public string ArtifactsDirectory { get; init; } = "artifacts"; - public bool Lint { get; init; } = true; - public bool Test { get; init; } = true; - public string TestRoot { get; init; } = "src/tests"; - public string[] TestPatterns { get; init; } = ["*Tests.csproj"]; - public string TestFilter { get; init; } = "/*/*/*/*/"; - public string[] TestArguments { get; init; } = ["--ignore-exit-code", "8"]; - public bool Pack { get; init; } = true; - public string PackTarget { get; init; } = "src"; - public string VersionFile { get; init; } = "package.json"; -} - -public enum ReleaseMode { None, LocalNuGet, NuGet, GitHubRelease } - -public sealed class ReleaseOptions -{ - public ReleaseMode Mode { get; init; } - public string NuGetFeed { get; init; } = "https://api.nuget.org/v3/index.json"; - public string? NuGetApiKey { get; init; } - public string? GitHubToken { get; init; } - public string? LocalFeed { get; init; } - public bool CreateGitHubRelease { get; init; } = true; -} diff --git a/src/Purview.Build/GlobalUsings.cs b/src/Purview.Build/GlobalUsings.cs index f6d76b8..b646ca7 100644 --- a/src/Purview.Build/GlobalUsings.cs +++ b/src/Purview.Build/GlobalUsings.cs @@ -1,16 +1,11 @@ global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using ModularPipelines; -global using ModularPipelines.Attributes; -global using ModularPipelines.Configuration; -global using ModularPipelines.Context; -global using ModularPipelines.DotNet.Extensions; -global using ModularPipelines.DotNet.Options; global using ModularPipelines.Extensions; -global using ModularPipelines.Models; -global using ModularPipelines.Modules; -global using NuGet.Versioning; global using Octokit; global using Octokit.Internal; +global using Purview.Build.Helpers; global using Purview.Build.Modules; +global using Purview.Build.Settings; \ No newline at end of file diff --git a/src/Purview.Build/Helpers/DotNetCLIOptions.cs b/src/Purview.Build/Helpers/DotNetCLIOptions.cs new file mode 100644 index 0000000..a96b09f --- /dev/null +++ b/src/Purview.Build/Helpers/DotNetCLIOptions.cs @@ -0,0 +1,9 @@ +using ModularPipelines.Options; + +namespace Purview.Build.Helpers; + +public sealed record DotNetCLIOptions : CommandLineToolOptions +{ + public static DotNetCLIOptions Create(params string[] commandParts) => + new() { Tool = "dotnet", CommandParts = commandParts }; +} \ No newline at end of file diff --git a/src/Purview.Build/Helpers/PathHelpers.cs b/src/Purview.Build/Helpers/PathHelpers.cs new file mode 100644 index 0000000..3607f70 --- /dev/null +++ b/src/Purview.Build/Helpers/PathHelpers.cs @@ -0,0 +1,23 @@ +namespace Purview.Build.Helpers; + +static class PathHelpers +{ + public static string FindRepositoryRoot(string? startDirectory = null) + { + if (string.IsNullOrEmpty(startDirectory)) + startDirectory = Environment.CurrentDirectory; + + DirectoryInfo? directory = new(startDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "package.json"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not locate the repository root (no package.json found). Run the tool from within the repository." + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Helpers/TestHelpers.cs b/src/Purview.Build/Helpers/TestHelpers.cs new file mode 100644 index 0000000..0557b11 --- /dev/null +++ b/src/Purview.Build/Helpers/TestHelpers.cs @@ -0,0 +1,39 @@ +namespace Purview.Build.Helpers; + +static class TestHelpers +{ + public static string BuildTUnitTreeNodeFilter( + string? assembly = null, + string? @namespace = null, + string? className = null, + string? testNameQuery = null + ) + { + var filter = "/"; + filter += assembly switch + { + null => "*", + _ => assembly, + }; + + filter += @namespace switch + { + null => "*", + _ => @namespace, + }; + + filter += className switch + { + null => "*", + _ => className, + }; + + filter += testNameQuery switch + { + null => "*", + _ => testNameQuery, + }; + + return filter; + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/BuildModule.cs b/src/Purview.Build/Modules/BuildModule.cs new file mode 100644 index 0000000..7d11ed3 --- /dev/null +++ b/src/Purview.Build/Modules/BuildModule.cs @@ -0,0 +1,30 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public class BuildModule(IOptions settings) : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + return await context + .DotNet() + .Build( + new() + { + ProjectSolution = settings.Value.Solution, + Configuration = settings.Value.Configuration, + NoRestore = true, + }, + cancellationToken: cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/BuildModules.cs b/src/Purview.Build/Modules/BuildModules.cs deleted file mode 100644 index 3d195fc..0000000 --- a/src/Purview.Build/Modules/BuildModules.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Text.Json; -using ModularPipelines.GitHub.Extensions; - -namespace Purview.Build.Modules; - -[ModuleCategory("Build")] -public sealed class VersionModule(IOptions options) : Module -{ - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - var path = Path.GetFullPath(options.Value.VersionFile); - using var json = JsonDocument.Parse(await File.ReadAllTextAsync(path, token)); - var value = json.RootElement.GetProperty("version").GetString(); - if (!NuGetVersion.TryParse(value, out var version)) - throw new InvalidOperationException($"'{value}' in {path} is not a valid semantic version."); - context.Summary.KeyValue("Version", "Package version", version.ToFullString()); - return version; - } -} - -[ModuleCategory("Build")] -public sealed class RestoreModule(IOptions options) : Module -{ - protected override Task ExecuteAsync(IModuleContext context, CancellationToken token) => - context.DotNet().Restore(new DotNetRestoreOptions { ProjectSolution = options.Value.Solution }, cancellationToken: token); -} - -[ModuleCategory("Build"), DependsOn] -public sealed class BuildModule(IOptions options) : Module -{ - protected override Task ExecuteAsync(IModuleContext context, CancellationToken token) => - context.DotNet().Build(new DotNetBuildOptions { - ProjectSolution = options.Value.Solution, Configuration = options.Value.Configuration, NoRestore = true - }, cancellationToken: token); -} - -[ModuleCategory("Build")] -public sealed class LintModule(IOptions options) : Module -{ - protected override ModuleConfiguration Configure() => ModuleConfiguration.Create() - .WithSkipWhen(_ => options.Value.Lint ? SkipDecision.DoNotSkip : SkipDecision.Skip("Lint is disabled.")).Build(); - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - await context.DotNet().Tool.Restore(new() { Interactive = false }, new(), token); - return await context.Shell.Command.ExecuteCommandLineTool( - new DotNetCommand { Tool = "dotnet", CommandParts = ["tool", "run", "csharpier", "check", Directory.GetCurrentDirectory()] }, - cancellationToken: token); - } -} - -[ModuleCategory("Build"), DependsOn] -public sealed class TestModule(IOptions options) : Module -{ - protected override ModuleConfiguration Configure() => ModuleConfiguration.Create() - .WithSkipWhen(_ => options.Value.Test ? SkipDecision.DoNotSkip : SkipDecision.Skip("Tests are disabled.")).Build(); - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - var settings = options.Value; - if (!Directory.Exists(settings.TestRoot)) return []; - var projects = settings.TestPatterns - .SelectMany(pattern => Directory.EnumerateFiles(settings.TestRoot, pattern, SearchOption.AllDirectories)) - .Distinct(StringComparer.OrdinalIgnoreCase); - var arguments = settings.TestArguments.Concat(string.IsNullOrWhiteSpace(settings.TestFilter) - ? [] : new[] { "--treenode-filter", settings.TestFilter }).ToArray(); - return await Task.WhenAll(projects.Select(project => context.DotNet().Test(new DotNetTestOptions { - Project = project, Configuration = settings.Configuration, NoBuild = true, NoRestore = true, Arguments = arguments - }, cancellationToken: token))); - } -} - -[ModuleCategory("Build"), DependsOn, DependsOn, DependsOn] -public sealed class PackModule(IOptions options) : Module -{ - protected override ModuleConfiguration Configure() => ModuleConfiguration.Create() - .WithSkipWhen(_ => options.Value.Pack ? SkipDecision.DoNotSkip : SkipDecision.Skip("Packing is disabled.")).Build(); - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - var version = (await context.GetModule()).ValueOrDefault!; - Directory.CreateDirectory(options.Value.ArtifactsDirectory); - return await context.DotNet().Pack(new DotNetPackOptions { - ProjectSolution = options.Value.PackTarget, Configuration = options.Value.Configuration, - Output = options.Value.ArtifactsDirectory, NoBuild = true, - Properties = [("PackageVersion", version.ToFullString()), ("Version", version.ToFullString())] - }, cancellationToken: token); - } -} - -public sealed record DotNetCommand : ModularPipelines.Options.CommandLineToolOptions; diff --git a/src/Purview.Build/Modules/CreateGitHubReleaseModule.cs b/src/Purview.Build/Modules/CreateGitHubReleaseModule.cs new file mode 100644 index 0000000..62a36ac --- /dev/null +++ b/src/Purview.Build/Modules/CreateGitHubReleaseModule.cs @@ -0,0 +1,84 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.GitHub.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Release")] +[DependsOn] +[DependsOn] +[DependsOn] +public class CreateGitHubReleaseModule( + IOptions releaseSettings, + IOptions gitSettings, + IOptions buildSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + releaseSettings.Value.Mode is not (ReleaseMode.NuGet or ReleaseMode.GitHubRelease) + || 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." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var versionResult = await context.GetModule(); + var version = + versionResult.ValueOrDefault + ?? throw new InvalidOperationException("The version was not produced by the version module."); + + var tag = $"v{version}"; + + var repositoryIdString = context.GitHub().EnvironmentVariables.RepositoryId; + if (!long.TryParse(repositoryIdString, out var repositoryId)) + { + throw new InvalidOperationException( + $"Failed to parse RepositoryId '{repositoryIdString}' as a valid long integer." + ); + } + + // Create a new release on GitHub with the specified tag and generate release notes + var release = await context + .GitHub() + .Client.Repository.Release.Create( + repositoryId, + new NewRelease(tag) { Name = tag, GenerateReleaseNotes = true } + ); + + 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, + } + ); + context.Logger.LogInformation("Uploaded release asset {File}.", Path.GetFileName(file)); + } + } + } + + return release; + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/LintModule.cs b/src/Purview.Build/Modules/LintModule.cs new file mode 100644 index 0000000..13841ba --- /dev/null +++ b/src/Purview.Build/Modules/LintModule.cs @@ -0,0 +1,45 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +public sealed class LintModule(IOptions settings) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + settings.Value.RunLint + ? SkipDecision.DoNotSkip + : SkipDecision.Skip("Linting is disabled. Set Build__RunLint=true to enable it.") + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var repositoryRoot = PathHelpers.FindRepositoryRoot(); + var dotnet = context.DotNet(); + var restoreResult = await dotnet.Tool.Restore( + new() { Interactive = false, ToolManifest = Path.Combine(repositoryRoot, ".config", "dotnet-tools.json") }, + new() { WorkingDirectory = repositoryRoot }, + cancellationToken + ); + if (restoreResult.ExitCode != 0) + return restoreResult; + + // Restore worked, now run the linter + return await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("tool", "run", "csharpier", "check", repositoryRoot), + new() { WorkingDirectory = repositoryRoot }, + cancellationToken: cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/PackModule.cs b/src/Purview.Build/Modules/PackModule.cs new file mode 100644 index 0000000..d614b67 --- /dev/null +++ b/src/Purview.Build/Modules/PackModule.cs @@ -0,0 +1,52 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +[DependsOn] +[DependsOn] +public sealed class PackModule(IOptions settings) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + !settings.Value.RunPack + ? SkipDecision.Skip("Packing is disabled. Set Build__RunPack=true to enable it.") + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var versionResult = await context.GetModule(); + var nugetVersion = + versionResult.ValueOrDefault + ?? throw new InvalidOperationException("The version was not produced by the version module."); + + Directory.CreateDirectory(settings.Value.ArtifactsFolder); + + var version = nugetVersion.ToString(); + return await context + .DotNet() + .Pack( + new DotNetPackOptions + { + ProjectSolution = settings.Value.Solution, + Configuration = settings.Value.Configuration, + Output = settings.Value.ArtifactsFolder, + Properties = [("PackageVersion", version), ("Version", version)], + }, + cancellationToken: cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/PublishLocalNuGetModule.cs b/src/Purview.Build/Modules/PublishLocalNuGetModule.cs new file mode 100644 index 0000000..d8380bd --- /dev/null +++ b/src/Purview.Build/Modules/PublishLocalNuGetModule.cs @@ -0,0 +1,197 @@ +using System.ComponentModel.DataAnnotations; +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using NuGet.Versioning; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +[DependsOn] +[DependsOn] +public class PublishLocalNuGetModule( + IOptions localNuGetFeedSettings, + IOptions releaseSettings, + IOptions buildSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(ctx => + !ctx.IsRunningLocally() || releaseSettings.Value.Mode != ReleaseMode.LocalNuGet + ? SkipDecision.Skip( + "Local NuGet Feed publishing is disabled. Run the pipeline locally with Release__Mode=LocalNuGet to enable it." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var settings = localNuGetFeedSettings.Value; + var localFeedPath = settings.GetLocalFeedPath(); + + var validationResults = new List(); + var validationContext = new ValidationContext(settings); + if ( + !Validator.TryValidateObject( + settings, + validationContext, + validationResults, + validateAllProperties: true + ) + ) + { + foreach (var validationResult in validationResults) + context.Logger.LogError("{Message}", validationResult.ErrorMessage); + + throw new InvalidOperationException( + $"Invalid {nameof(PublishLocalNuGetSettings)} configuration for {nameof(PublishLocalNuGetSettings.LocalFeedPath)}. " + + "Windows paths with backslashes may have been stripped by the shell; " + + "use forward slashes, e.g. --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/." + ); + } + + var fullLocalFeedPath = Path.GetFullPath(localFeedPath!); + context.Logger.LogInformation("Publishing local NuGet packages to {LocalFeedPath}.", fullLocalFeedPath); + + if (!Directory.Exists(fullLocalFeedPath)) + Directory.CreateDirectory(fullLocalFeedPath); + + var packages = Directory + .GetFiles(buildSettings.Value.ArtifactsFolder, "*.nupkg") + .Concat(Directory.GetFiles(buildSettings.Value.ArtifactsFolder, "*.snupkg")) + .ToArray(); + if (packages.Length == 0) + { + throw new InvalidOperationException( + $"No packages found in {buildSettings.Value.ArtifactsFolder}. The local feed was not populated." + ); + } + + List nupkgPackages = []; + foreach (var package in packages) + { + var fileName = Path.GetFileName(package); + var destinationPath = Path.Combine(fullLocalFeedPath, fileName); + + if (Path.GetExtension(fileName) == ".nupkg") + nupkgPackages.Add(await ParsePackageDetailsAsync(package, cancellationToken)); + + if (!settings.OverwriteExistingPackages && File.Exists(destinationPath)) + { + context.Logger.LogInformation("Package {Package} already exists in local feed. Skipping.", fileName); + File.Delete(package); + + continue; + } + + File.Move(package, destinationPath, true); + context.Logger.LogInformation("Copied package {Package} to local feed.", fileName); + } + + if (settings.ClearPackageCache) + { + context.Logger.LogInformation("Clearing local NuGet package cache..."); + + var globalPackagesResult = await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("nuget", "locals", "global-packages", "--list"), + cancellationToken: cancellationToken + ); + if (globalPackagesResult.ExitCode != 0) + return globalPackagesResult; + + var httpCacheResult = await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("nuget", "locals", "http-cache", "--list"), + cancellationToken: cancellationToken + ); + if (httpCacheResult.ExitCode != 0) + return httpCacheResult; + + var globalPackagePaths = globalPackagesResult + .StandardOutput.Replace("global-packages: ", "", StringComparison.Ordinal) + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(Directory.Exists); + + var httpCachePaths = httpCacheResult + .StandardOutput.Replace("http-cache: ", "", StringComparison.Ordinal) + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(Directory.Exists); + + foreach (var artifact in nupkgPackages) + { +#pragma warning disable CA1308 // Normalize strings to uppercase + var loweredPackageId = artifact.PackageId.ToLowerInvariant(); + var loweredVersion = artifact.Version.ToFullString().ToLowerInvariant(); +#pragma warning restore CA1308 // Normalize strings to uppercase + + foreach (var globalPath in globalPackagePaths) + { + var packagePath = Path.Combine(globalPath, loweredPackageId, artifact.Version.ToFullString()); + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + context.Logger.LogInformation( + "Deleted package {Package} version {Version} from global packages cache.", + artifact.PackageId, + artifact.Version + ); + } + } + foreach (var httpCachePath in httpCachePaths) + { + string[] packagePaths = + [ + Path.Combine(httpCachePath, "list_" + loweredPackageId + ".dat"), + Path.Combine(httpCachePath, "list_" + loweredPackageId + "_index.dat"), + Path.Combine(httpCachePath, "list_" + loweredPackageId + "_range_*.dat"), + Path.Combine(httpCachePath, "nupkg_" + loweredPackageId + "." + loweredVersion + ".dat"), + ]; + + foreach (var path in packagePaths) + { + var directory = Path.GetDirectoryName(path); + var pattern = Path.GetFileName(path); + foreach (var file in Directory.EnumerateFiles(directory!, pattern, SearchOption.AllDirectories)) + { + File.Delete(file); + context.Logger.LogInformation( + "Deleted package {Package} version {Version} from HTTP cache.", + artifact.PackageId, + artifact.Version + ); + } + } + } + } + } + + if (settings.ShutdownDotnetBuilderServer) + { + context.Logger.LogInformation("Shutting down dotnet builder server..."); + + return await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("build-server", "shutdown"), + cancellationToken: cancellationToken + ); + } + + return null; + } + + static async Task ParsePackageDetailsAsync(string artifact, CancellationToken cancellationToken) + { + using var packageReader = new NuGet.Packaging.PackageArchiveReader(artifact); + var packaging = await packageReader.GetNuspecReaderAsync(cancellationToken); + + return new(packaging.GetId(), packaging.GetVersion()); + } +} + +record struct PackageDetails(string PackageId, NuGetVersion Version); \ No newline at end of file diff --git a/src/Purview.Build/Modules/PublishNuGetModule.cs b/src/Purview.Build/Modules/PublishNuGetModule.cs new file mode 100644 index 0000000..d62db80 --- /dev/null +++ b/src/Purview.Build/Modules/PublishNuGetModule.cs @@ -0,0 +1,76 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Release")] +[DependsOn] +[DependsOn] +[DependsOn] +public class PublishNuGetModule( + IOptions buildSettings, + IOptions nugetSettings, + IOptions releaseSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + releaseSettings.Value.Mode != ReleaseMode.NuGet + || ( + !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." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var artifactsFolder = buildSettings.Value.ArtifactsFolder; + if (!Directory.Exists(artifactsFolder)) + { + throw new InvalidOperationException( + $"The artifacts folder '{artifactsFolder}' does not exist. " + + "Ensure the pack step ran (Release__Mode must not be None) before publishing." + ); + } + + var packages = Directory.EnumerateFiles(artifactsFolder, "*.nupkg", SearchOption.TopDirectoryOnly).ToList(); + + if (packages.Count == 0) + { + throw new InvalidOperationException($"No NuGet packages found in {buildSettings.Value.ArtifactsFolder}."); + } + + var tasks = packages.Select(package => + context + .DotNet() + .Nuget.Push( + new() + { + Path = package, + Source = nugetSettings.Value.FeedUrl, + ApiKey = nugetSettings.Value.TrustedPublishing + ? null + : nugetSettings.Value.GetNuGetAPIKey(), + SkipDuplicate = true, + }, + cancellationToken: cancellationToken + ) + ); + + return await Task.WhenAll(tasks); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/ReleaseModules.cs b/src/Purview.Build/Modules/ReleaseModules.cs deleted file mode 100644 index 18dc2c6..0000000 --- a/src/Purview.Build/Modules/ReleaseModules.cs +++ /dev/null @@ -1,39 +0,0 @@ -using ModularPipelines.GitHub.Extensions; - -namespace Purview.Build.Modules; - -[ModuleCategory("Release"), DependsOn] -public sealed class PublishModule(IOptions build, IOptions release) : Module -{ - protected override ModuleConfiguration Configure() => ModuleConfiguration.Create().WithSkipWhen(_ => - release.Value.Mode is ReleaseMode.NuGet or ReleaseMode.LocalNuGet ? SkipDecision.DoNotSkip : SkipDecision.Skip("Package publishing is disabled.")).Build(); - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - var settings = release.Value; - var source = settings.Mode == ReleaseMode.LocalNuGet ? settings.LocalFeed : settings.NuGetFeed; - if (string.IsNullOrWhiteSpace(source)) throw new InvalidOperationException("The package feed is not configured."); - Directory.CreateDirectory(settings.Mode == ReleaseMode.LocalNuGet ? Path.GetFullPath(source) : build.Value.ArtifactsDirectory); - var key = settings.NuGetApiKey ?? Environment.GetEnvironmentVariable("NUGET_API_KEY") ?? "local"; - var packages = Directory.EnumerateFiles(build.Value.ArtifactsDirectory, "*.nupkg", SearchOption.TopDirectoryOnly); - return await Task.WhenAll(packages.Select(package => context.DotNet().Nuget.Push(new() { - Path = package, Source = source, ApiKey = key, SkipDuplicate = true - }, cancellationToken: token))); - } -} - -[ModuleCategory("Release"), DependsOn, DependsOn] -public sealed class GitHubReleaseModule(IOptions release) : Module -{ - protected override ModuleConfiguration Configure() => ModuleConfiguration.Create().WithSkipWhen(_ => - release.Value.CreateGitHubRelease && release.Value.Mode is ReleaseMode.NuGet or ReleaseMode.GitHubRelease - ? SkipDecision.DoNotSkip : SkipDecision.Skip("GitHub release creation is disabled.")).Build(); - protected override async Task ExecuteAsync(IModuleContext context, CancellationToken token) - { - var version = (await context.GetModule()).ValueOrDefault!; - if (!long.TryParse(context.GitHub().EnvironmentVariables.RepositoryId, out var repositoryId)) - throw new InvalidOperationException("GITHUB_REPOSITORY_ID is missing or invalid."); - var tag = $"v{version}"; - return await context.GitHub().Client.Repository.Release.Create(repositoryId, - new NewRelease(tag) { Name = tag, GenerateReleaseNotes = true }); - } -} diff --git a/src/Purview.Build/Modules/RestoreModule.cs b/src/Purview.Build/Modules/RestoreModule.cs new file mode 100644 index 0000000..81f05b4 --- /dev/null +++ b/src/Purview.Build/Modules/RestoreModule.cs @@ -0,0 +1,25 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +public class RestoreModule(IOptions settings) : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + return await context + .DotNet() + .Restore( + new DotNetRestoreOptions { ProjectSolution = settings.Value.Solution }, + cancellationToken: cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/RunTestsModule.cs b/src/Purview.Build/Modules/RunTestsModule.cs new file mode 100644 index 0000000..476fb84 --- /dev/null +++ b/src/Purview.Build/Modules/RunTestsModule.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public class RunTestsModule(IOptions settings) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + settings.Value.RunTests + ? SkipDecision.DoNotSkip + : SkipDecision.Skip("Tests are disabled. Set Build__RunTests=true to run them.") + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var testRoot = settings.Value.TestRoot; + var testProjects = FilterTestProjects( + Directory + .EnumerateFiles(testRoot, "*.csproj", SearchOption.AllDirectories) + .Where(project => IsTestProject(project, settings.Value.TestPatterns)) + .ToList(), + settings.Value.TestProjects + ); + if (testProjects.Count == 0) + { + context.Logger.LogWarning( + "No test projects matched '{TestRoot}' (patterns: {TestPatterns}, filter: {TestProjects}), despite tests being enabled. Skipping test execution.", + testRoot, + settings.Value.TestPatterns, + settings.Value.TestProjects + ); + + return []; + } + + var timings = new List<(string Project, TimeSpan Elapsed, int ExitCode)>(); + + var tasks = testProjects.Select(async project => + { + var stopwatch = Stopwatch.StartNew(); + var result = await context + .DotNet() + .Test( + new DotNetTestOptions + { + Project = project, + Configuration = settings.Value.Configuration, + NoBuild = true, + NoRestore = true, + Arguments = BuildTestArguments(settings.Value), + }, + cancellationToken: cancellationToken + ); + stopwatch.Stop(); + + lock (timings) + timings.Add((project, stopwatch.Elapsed, result.ExitCode)); + + return result; + }); + + var results = await Task.WhenAll(tasks); + + context.Logger.LogInformation( + "Test run timings:{NewLine}{Timings}", + Environment.NewLine, + string.Join( + Environment.NewLine, + timings + .OrderByDescending(t => t.Elapsed) + .Select(t => $" {Path.GetFileName(t.Project)}: {t.Elapsed.TotalSeconds:F1}s (exit {t.ExitCode})") + ) + ); + + return results; + } + + static IReadOnlyList BuildTestArguments(BuildSettings buildSettings) + { + var arguments = new List(); + + if (buildSettings.TestFramework == TestFramework.TUnit) + { + // Microsoft.Testing.Platform exits with code 8 when no tests are selected; treat it as success. + arguments.Add("--ignore-exit-code"); + arguments.Add("8"); + + if (!string.IsNullOrWhiteSpace(buildSettings.TestFilter)) + { + arguments.Add("--treenode-filter"); + arguments.Add(buildSettings.TestFilter); + } + } + else if (!string.IsNullOrWhiteSpace(buildSettings.TestFilter)) + { + arguments.Add("--filter"); + arguments.Add(buildSettings.TestFilter); + } + + return arguments; + } + + static bool IsTestProject(string project, string patterns) + { + if (string.IsNullOrWhiteSpace(patterns) || patterns.Trim() == "*") + return true; + + var fileName = Path.GetFileName(project); + return patterns + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ToRegexPattern) + .Any(pattern => Regex.IsMatch(fileName, pattern, RegexOptions.IgnoreCase)); + } + + static IReadOnlyList FilterTestProjects(IReadOnlyList projects, string filter) + { + if (string.IsNullOrWhiteSpace(filter) || filter.Trim() == "*") + return projects; + + var patterns = filter + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ToRegexPattern) + .ToArray(); + + return projects + .Where(project => + { + var fileName = Path.GetFileName(project); + return patterns.Any(pattern => Regex.IsMatch(fileName, pattern, RegexOptions.IgnoreCase)); + }) + .ToList(); + } + + static string ToRegexPattern(string entry) + { + if (entry.Contains('*', StringComparison.Ordinal)) + { + var escaped = Regex.Escape(entry); + return "^" + escaped.Replace("\\*", ".*", StringComparison.Ordinal) + "$"; + } + + return "^" + Regex.Escape(entry) + "$"; + } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/ValidatePackModule.cs b/src/Purview.Build/Modules/ValidatePackModule.cs new file mode 100644 index 0000000..a546d88 --- /dev/null +++ b/src/Purview.Build/Modules/ValidatePackModule.cs @@ -0,0 +1,344 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using NuGet.Packaging; +using NuGet.Versioning; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public sealed class ValidatePackModule( + IOptions buildSettings, + IOptions packValidationSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + !buildSettings.Value.ValidatePack + ? SkipDecision.Skip("Pack validation is disabled. Set Build__ValidatePack=true to enable it.") + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var artifactsFolder = Path.GetFullPath(buildSettings.Value.ArtifactsFolder); + if (!Directory.Exists(artifactsFolder)) + { + throw new InvalidOperationException( + $"The artifacts folder '{artifactsFolder}' does not exist. Run the pack step first." + ); + } + + var nupkgFiles = Directory.EnumerateFiles(artifactsFolder, "*.nupkg", SearchOption.TopDirectoryOnly).ToArray(); + var snupkgFiles = Directory + .EnumerateFiles(artifactsFolder, "*.snupkg", SearchOption.TopDirectoryOnly) + .ToArray(); + + if (nupkgFiles.Length == 0) + { + throw new InvalidOperationException($"No .nupkg files found in {artifactsFolder}."); + } + + var results = new List(nupkgFiles.Length + snupkgFiles.Length); + var packagePairs = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var package in nupkgFiles) + { + var result = await ValidateNupkgAsync(package, packValidationSettings.Value, cancellationToken); + results.Add(result); + + var pair = GetOrAddPair(packagePairs, result.PackageKey); + pair.Nupkg = result; + } + + foreach (var package in snupkgFiles) + { + var result = await ValidateSnupkgAsync(package, packValidationSettings.Value, cancellationToken); + results.Add(result); + + var pair = GetOrAddPair(packagePairs, result.PackageKey); + pair.Snupkg = result; + } + + if (packValidationSettings.Value.RequireSymbolPackage) + { + foreach (var pair in packagePairs.Values) + { + if (pair.Nupkg is not null && pair.Snupkg is null) + { + pair.Nupkg.AddError( + $"Package '{pair.Nupkg.PackageId}' {pair.Nupkg.Version.ToNormalizedString()} has no matching .snupkg." + ); + } + + if (pair.Snupkg is not null && pair.Nupkg is null) + { + pair.Snupkg.AddError( + $"Symbol package '{pair.Snupkg.PackageId}' {pair.Snupkg.Version.ToNormalizedString()} has no matching .nupkg." + ); + } + } + } + + var invalid = results.Where(result => result.Errors.Count > 0).ToList(); + foreach (var result in results) + { + if (result.Errors.Count == 0) + { + context.Logger.LogInformation( + "Validated {FileName} ({Kind}): {PackageId} {Version}.", + result.FileName, + result.Kind, + result.PackageId, + result.Version.ToNormalizedString() + ); + } + else + { + foreach (var error in result.Errors) + context.Logger.LogError("{FileName}: {Error}", result.FileName, error); + } + } + + var validCount = results.Count - invalid.Count; + context.Summary.KeyValue("PackValidation", "Valid packages", $"{validCount}/{results.Count}"); + context.Summary.KeyValue("PackValidation", "Invalid packages", $"{invalid.Count}/{results.Count}"); + + if (invalid.Count > 0) + { + var detail = string.Join( + Environment.NewLine, + invalid.Select(result => + $" {result.FileName}:{Environment.NewLine} " + + string.Join(Environment.NewLine + " ", result.Errors) + ) + ); + + throw new InvalidOperationException( + $"Pack validation failed for {invalid.Count} of {results.Count} package(s):{Environment.NewLine}{detail}" + ); + } + + return results.ToArray(); + } + + static async Task ValidateNupkgAsync( + string packagePath, + PackValidationSettings settings, + CancellationToken cancellationToken + ) + { + var errors = new List(); + + try + { + using var reader = new PackageArchiveReader(packagePath); + var nuspec = await reader.GetNuspecReaderAsync(cancellationToken); + var id = nuspec.GetId(); + var version = nuspec.GetVersion(); + + ValidateFileName(packagePath, id, version, ".nupkg", errors); + + var files = reader.GetFiles().ToArray(); + ValidateNoPdbFiles(files, errors); + + var required = GetContentRule(settings.RequiredContent, id); + if (required is not null) + { + foreach (var entry in required) + { + if (!files.Contains(entry, StringComparer.OrdinalIgnoreCase)) + errors.Add($"Required content '{entry}' is missing from the package."); + } + } + + var forbidden = GetContentRule(settings.ForbiddenContent, id); + if (forbidden is not null) + { + foreach (var entry in forbidden) + { + if (files.Contains(entry, StringComparer.OrdinalIgnoreCase)) + errors.Add($"Forbidden content '{entry}' must not be in the package."); + } + } + + var result = new PackValidationResult( + Path.GetFileName(packagePath), + "nupkg", + CreatePackageKey(id, version), + id, + version + ); + result.AddErrors(errors); + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var result = new PackValidationResult( + Path.GetFileName(packagePath), + "nupkg", + Path.GetFileName(packagePath) + "|unreadable", + "", + new NuGetVersion(0, 0, 0) + ); + result.AddError($"Failed to read package: {ex.Message}"); + return result; + } + } + + static async Task ValidateSnupkgAsync( + string packagePath, + PackValidationSettings settings, + CancellationToken cancellationToken + ) + { + var errors = new List(); + + try + { + using var reader = new PackageArchiveReader(packagePath); + var nuspec = await reader.GetNuspecReaderAsync(cancellationToken); + var id = nuspec.GetId(); + var version = nuspec.GetVersion(); + + ValidateFileName(packagePath, id, version, ".snupkg", errors); + + var files = reader.GetFiles().ToArray(); + var nonSymbolFiles = files.Where(file => !IsPdbFile(file) && !IsSymbolPackageMetadata(file)).ToArray(); + if (nonSymbolFiles.Length > 0) + errors.Add($"Symbol package contains non-symbol file(s): {string.Join(", ", nonSymbolFiles)}."); + + if (settings.RequireSymbolFiles && !files.Any(IsPdbFile)) + errors.Add("Symbol package contains no .pdb files."); + + var result = new PackValidationResult( + Path.GetFileName(packagePath), + "snupkg", + CreatePackageKey(id, version), + id, + version + ); + result.AddErrors(errors); + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var result = new PackValidationResult( + Path.GetFileName(packagePath), + "snupkg", + Path.GetFileName(packagePath) + "|unreadable", + "", + new NuGetVersion(0, 0, 0) + ); + result.AddError($"Failed to read package: {ex.Message}"); + return result; + } + } + + static void ValidateFileName( + string packagePath, + string id, + NuGetVersion version, + string extension, + List errors + ) + { + var expected = $"{id}.{version.ToNormalizedString()}{extension}"; + if (!string.Equals(Path.GetFileName(packagePath), expected, StringComparison.OrdinalIgnoreCase)) + errors.Add( + $"File name '{Path.GetFileName(packagePath)}' does not match the nuspec id/version '{expected}'." + ); + } + + static void ValidateNoPdbFiles(IEnumerable files, List errors) + { + var pdbFiles = files.Where(IsPdbFile).ToArray(); + if (pdbFiles.Length > 0) + errors.Add( + $"Package contains PDB file(s): {string.Join(", ", pdbFiles)}. " + + "PDBs must only be delivered through the .snupkg." + ); + } + + static bool IsPdbFile(string path) => + string.Equals(Path.GetExtension(path), ".pdb", StringComparison.OrdinalIgnoreCase); + + static bool IsSymbolPackageMetadata(string path) => + string.Equals(path, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("package/services/metadata/", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase); + + static string[]? GetContentRule(Dictionary rules, string packageId) + { + if (rules.TryGetValue(packageId, out var exact)) + return exact; + + foreach (var rule in rules) + { + if (string.Equals(rule.Key, packageId, StringComparison.OrdinalIgnoreCase)) + return rule.Value; + } + + return null; + } + + static string CreatePackageKey(string id, NuGetVersion version) => $"{id}|{version.ToNormalizedString()}"; + + static PackagePair GetOrAddPair(Dictionary pairs, string key) + { + if (!pairs.TryGetValue(key, out var pair)) + { + pair = new PackagePair(); + pairs.Add(key, pair); + } + + return pair; + } +} + +public sealed class PackValidationResult +{ + readonly List _errors = []; + + public PackValidationResult(string fileName, string kind, string packageKey, string packageId, NuGetVersion version) + { + FileName = fileName; + Kind = kind; + PackageKey = packageKey; + PackageId = packageId; + Version = version; + } + + public string FileName { get; } + + public string Kind { get; } + + public string PackageKey { get; } + + public string PackageId { get; } + + public NuGetVersion Version { get; } + + public IReadOnlyList Errors => _errors; + + internal void AddError(string error) => _errors.Add(error); + + internal void AddErrors(IEnumerable errors) => _errors.AddRange(errors); +} + +sealed class PackagePair +{ + public PackValidationResult? Nupkg { get; set; } + + public PackValidationResult? Snupkg { get; set; } +} \ No newline at end of file diff --git a/src/Purview.Build/Modules/VersionModule.cs b/src/Purview.Build/Modules/VersionModule.cs new file mode 100644 index 0000000..e9716e8 --- /dev/null +++ b/src/Purview.Build/Modules/VersionModule.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Modules; +using NuGet.Versioning; + +namespace Purview.Build.Modules; + +[ModuleCategory("Build")] +public class VersionModule : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + 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."); + + context.Summary.KeyValue("Version", "Package version", version); + return nugetVersion; + } +} \ No newline at end of file diff --git a/src/Purview.Build/PipelineProjectDirectory.cs b/src/Purview.Build/PipelineProjectDirectory.cs new file mode 100644 index 0000000..88b3a54 --- /dev/null +++ b/src/Purview.Build/PipelineProjectDirectory.cs @@ -0,0 +1,52 @@ +using System.Runtime.CompilerServices; + +namespace Purview.Build; + +static class PipelineProjectDirectory +{ + const string DirectoryVariable = "MODULAR_PIPELINES_DIRECTORY"; + + public static string Find([CallerFilePath] string sourceFilePath = "") + { + var configuredDirectory = Environment.GetEnvironmentVariable(DirectoryVariable); + if (!string.IsNullOrWhiteSpace(configuredDirectory)) + { + return ValidateConfiguredDirectory(configuredDirectory); + } + + var sourceDirectory = Path.GetDirectoryName(sourceFilePath); + return IsPipelineDirectory(sourceDirectory) ? sourceDirectory! : FindFromBuildOutput(); + } + + static string ValidateConfiguredDirectory(string configuredDirectory) + { + var fullPath = Path.GetFullPath(configuredDirectory); + return IsPipelineDirectory(fullPath) + ? fullPath + : throw new InvalidOperationException( + $"{DirectoryVariable} must point to a directory containing appsettings.json." + ); + } + + static string FindFromBuildOutput() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if (IsPipelineDirectory(directory.FullName)) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"Could not locate the pipeline project directory. Set {DirectoryVariable} to its path." + ); + } + + static bool IsPipelineDirectory(string? directory) => + directory is not null && File.Exists(Path.Combine(directory, "appsettings.json")); +} \ No newline at end of file diff --git a/src/Purview.Build/Program.cs b/src/Purview.Build/Program.cs index 6418052..61a103f 100644 --- a/src/Purview.Build/Program.cs +++ b/src/Purview.Build/Program.cs @@ -1,24 +1,49 @@ using Purview.Build; -var root = Directory.GetCurrentDirectory(); +var pipelineDirectory = PipelineProjectDirectory.Find(); +var repositoryRoot = PathHelpers.FindRepositoryRoot(Environment.CurrentDirectory); + var builder = Pipeline.CreateBuilder(args); -builder.Configuration - .AddJsonFile(Path.Combine(root, "purview-build.json"), optional: true) - .AddEnvironmentVariables() - .AddCommandLine(args); - -builder.Services.Configure(builder.Configuration.GetSection("Build")); -builder.Services.Configure(builder.Configuration.GetSection("Release")); -builder.Services.AddSingleton(services => + +builder + .Configuration.AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false) + .AddJsonFile(Path.Combine(repositoryRoot, "purview-build.json"), optional: true) + .AddEnvironmentVariables() + .AddCommandLine(args); + +builder.Services.Configure(builder.Configuration.GetSection(BuildSettings.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(NuGetSettings.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(PackValidationSettings.SectionName) +); +builder.Services.Configure( + builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName) +); +builder.Services.Configure(builder.Configuration.GetSection(GitHubSettings.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(ReleaseSettings.SectionName)); + +builder.Services.AddSingleton(serviceProvider => { - var token = services.GetRequiredService>().Value.GitHubToken - ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? string.Empty; - return new GitHubClient(new ProductHeaderValue("Purview.Build"), new InMemoryCredentialStore(new Credentials(token))); + var settings = serviceProvider.GetRequiredService>(); + var accessToken = settings.Value.GetGitHubToken(); + + return new GitHubClient(new(settings.Value.ProductHeader), new InMemoryCredentialStore(new(accessToken))); }); -builder.AddModule().AddModule().AddModule() - .AddModule().AddModule().AddModule() - .AddModule().AddModule(); +Environment.CurrentDirectory = repositoryRoot; + +builder + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule(); await using var pipeline = await builder.BuildAsync(); -await pipeline.RunAsync(); + +await pipeline.RunAsync(); \ No newline at end of file diff --git a/src/Purview.Build/Purview.Build.csproj b/src/Purview.Build/Purview.Build.csproj index 70a94c7..3bb1e9f 100644 --- a/src/Purview.Build/Purview.Build.csproj +++ b/src/Purview.Build/Purview.Build.csproj @@ -1,26 +1,32 @@ - - Exe - net10.0 - enable - enable - true - purview-build - Purview.Build - 0.1.0 - purview-dev - Shared Modular Pipelines build tool for purview-dev repositories. - README.md - https://github.com/purview-dev/build - MIT - - - - - - - - - - - + + Exe + net10.0 + enable + enable + true + purview-build + Purview.Build + purview-dev + Shared Modular Pipelines build/test/release tool for purview-dev repositories. + README.md + https://github.com/purview-dev/build + MIT + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/Purview.Build/Settings/BuildSettings.cs b/src/Purview.Build/Settings/BuildSettings.cs new file mode 100644 index 0000000..e095208 --- /dev/null +++ b/src/Purview.Build/Settings/BuildSettings.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; + +namespace Purview.Build.Settings; + +public enum TestFramework +{ + TUnit, + + xUnit, +} + +public sealed class BuildSettings +{ + public const string SectionName = "Build"; + + public LogLevel LogLevel { get; init; } = LogLevel.Warning; + + [Required(AllowEmptyStrings = false)] + public string Solution { get; init; } = "src/Product.slnx"; + + [Required(AllowEmptyStrings = false)] + public string Configuration { get; init; } = "Release"; + + [Required(AllowEmptyStrings = false)] + public string ArtifactsFolder { get; init; } = "artifacts"; + + public bool RunTests { get; init; } = true; + + /// + /// Root directory (relative to the repository root) under which test projects are discovered. + /// + [Required(AllowEmptyStrings = false)] + public string TestRoot { get; init; } = "src/tests"; + + /// + /// Comma-separated project search patterns, recursively applied under . + /// + [Required(AllowEmptyStrings = false)] + public string TestPatterns { get; init; } = "*Tests.csproj"; + + /// + /// Comma-separated list of test project file names (or glob patterns) to run. + /// Empty or "*" runs every discovered test project. + /// + public string TestProjects { get; init; } = "*"; + + public TestFramework TestFramework { get; init; } = TestFramework.TUnit; + + /// + /// Test filter. For TUnit this is a Microsoft.Testing.Platform tree-node filter + /// (e.g. "/*/*/*/*[Category=Unit]"); for xUnit it is a VSTest filter (e.g. "Category=Unit"). + /// Empty disables the filter. + /// + public string TestFilter { get; init; } = "/*/*/*/*/"; + + public bool RunLint { get; init; } = true; + + public bool RunPack { get; init; } = true; + + public bool ValidatePack { get; init; } = true; +} \ No newline at end of file diff --git a/src/Purview.Build/Settings/GitHubSettings.cs b/src/Purview.Build/Settings/GitHubSettings.cs new file mode 100644 index 0000000..8fa3652 --- /dev/null +++ b/src/Purview.Build/Settings/GitHubSettings.cs @@ -0,0 +1,32 @@ +using ModularPipelines.Attributes; + +namespace Purview.Build.Settings; + +public sealed record GitHubSettings +{ + public const string SectionName = "GitHub"; + + [SecretValue] + public string? AccessToken { get; init; } + + [SecretValue] + [ConfigurationKeyName("GITHUB_TOKEN")] + public string? EnvAccessToken { get; init; } + + public string ProductHeader { get; init; } = "Purview.Build.Pipeline"; + + public string? GetGitHubToken() + { + if (!string.IsNullOrWhiteSpace(AccessToken)) + return AccessToken; + + if (!string.IsNullOrWhiteSpace(EnvAccessToken)) + return EnvAccessToken; + + // GitHub Actions provisions the automatic GITHUB_TOKEN as a plain environment variable. + // The config binder keys it under the "GitHub" section (GitHub:GITHUB_TOKEN), which the + // standard GITHUB_TOKEN env var does not map to, so read it directly as a fallback. + var processToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + return string.IsNullOrWhiteSpace(processToken) ? null : processToken; + } +} \ No newline at end of file diff --git a/src/Purview.Build/Settings/NuGetSettings.cs b/src/Purview.Build/Settings/NuGetSettings.cs new file mode 100644 index 0000000..620ee81 --- /dev/null +++ b/src/Purview.Build/Settings/NuGetSettings.cs @@ -0,0 +1,42 @@ +using ModularPipelines.Attributes; + +namespace Purview.Build.Settings; + +public sealed record NuGetSettings +{ + public const string SectionName = "NuGet"; + + [SecretValue] + public string? APIKey { get; set; } + + [SecretValue] + [ConfigurationKeyName("NUGET_APIKEY")] + public string? EnvAPIKey { get; set; } + + public string FeedUrl { get; init; } = "https://api.nuget.org/v3/index.json"; + + /// + /// When true, packages are pushed without an API key using NuGet Trusted Publishing + /// (OIDC federation, e.g. via the NuGet/login GitHub Action). No API key is required. + /// + public bool TrustedPublishing { get; init; } + + public string? GetNuGetAPIKey() => + !string.IsNullOrWhiteSpace(APIKey) ? APIKey + : !string.IsNullOrWhiteSpace(EnvAPIKey) ? EnvAPIKey + : GetProcessAPIKey(); + + static string? GetProcessAPIKey() + { + // GitHub Actions and other CI inject NUGET_APIKEY as a plain environment variable, which the + // config binder does not map under the "NuGet" section. Read it directly as a fallback. + foreach (var name in new[] { "NUGET_APIKEY", "NUGET_API_KEY" }) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Purview.Build/Settings/PackValidationSettings.cs b/src/Purview.Build/Settings/PackValidationSettings.cs new file mode 100644 index 0000000..4c90d3e --- /dev/null +++ b/src/Purview.Build/Settings/PackValidationSettings.cs @@ -0,0 +1,28 @@ +namespace Purview.Build.Settings; + +public sealed record PackValidationSettings +{ + public const string SectionName = "PackValidation"; + + /// + /// Every .nupkg must have a matching .snupkg (same id/version) and vice versa. + /// + public bool RequireSymbolPackage { get; init; } = true; + + /// + /// Every .snupkg must contain at least one .pdb file. + /// + public bool RequireSymbolFiles { get; init; } = true; + + /// + /// Package id (case-insensitive) to entry paths that MUST be present in the .nupkg. + /// Entry paths use forward slashes, e.g. "lib/netstandard2.0/Foo.dll". + /// + public Dictionary RequiredContent { get; init; } = []; + + /// + /// Package id (case-insensitive) to entry paths that MUST NOT be present in the .nupkg. + /// Entry paths use forward slashes, e.g. "lib/netstandard2.0/Foo.dll". + /// + public Dictionary ForbiddenContent { get; init; } = []; +} \ No newline at end of file diff --git a/src/Purview.Build/Settings/PublishLocalNuGetSettings.cs b/src/Purview.Build/Settings/PublishLocalNuGetSettings.cs new file mode 100644 index 0000000..3e307bb --- /dev/null +++ b/src/Purview.Build/Settings/PublishLocalNuGetSettings.cs @@ -0,0 +1,110 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Purview.Build.Settings; + +public sealed record PublishLocalNuGetSettings : IValidatableObject +{ + public const string SectionName = "PublishLocalNuGet"; + + public string LocalFeedPath { get; init; } = string.Empty; + + /// + /// Env-var bound alias for via + /// PublishLocalNuGet__LOCAL_NUGET_FEED_PATH. + /// + [ConfigurationKeyName("LOCAL_NUGET_FEED_PATH")] + public string? EnvLocalFeedPath { get; init; } + + public bool OverwriteExistingPackages { get; init; } = true; + + public bool ShutdownDotnetBuilderServer { get; init; } = true; + + public bool ClearPackageCache { get; init; } = true; + + /// + /// Resolves the configured local feed path, falling back to the env-bound value + /// (PublishLocalNuGet__LOCAL_NUGET_FEED_PATH) and then to the plain + /// LOCAL_NUGET_FEED_PATH process environment variable. + /// + public string? GetLocalFeedPath() + { + if (!string.IsNullOrWhiteSpace(LocalFeedPath)) + return LocalFeedPath; + + if (!string.IsNullOrWhiteSpace(EnvLocalFeedPath)) + return EnvLocalFeedPath; + + var processValue = Environment.GetEnvironmentVariable("LOCAL_NUGET_FEED_PATH"); + return string.IsNullOrWhiteSpace(processValue) ? null : processValue; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + var localFeedPath = GetLocalFeedPath(); + if (string.IsNullOrWhiteSpace(localFeedPath)) + { + yield return new ValidationResult( + "LocalFeedPath (or LOCAL_NUGET_FEED_PATH) is required.", + [nameof(LocalFeedPath)] + ); + yield break; + } + + // Path.IsPathRooted("p:foo") returns true, but a drive-relative path like "p:foo" is NOT an + // absolute path: Path.GetFullPath resolves it against the current directory and can silently + // copy packages to an unintended location. This is the classic signature of a Windows path whose + // backslashes were stripped by a sh-style shell, e.g. 'p:\_sync-projects\.local-nuget\'. + if (localFeedPath.Length >= 2 && localFeedPath[1] == ':') + { + var hasSeparatorAfterDrive = + localFeedPath.Length >= 3 + && ( + localFeedPath[2] == Path.DirectorySeparatorChar + || localFeedPath[2] == Path.AltDirectorySeparatorChar + ); + if (!hasSeparatorAfterDrive) + { + yield return new ValidationResult( + $"LocalFeedPath '{localFeedPath}' is drive-relative, not an absolute path. " + + "This is usually caused by the shell stripping backslashes from a Windows path such as " + + $"'p:\\_sync-projects\\.local-nuget\\'. Use forward slashes instead, e.g. " + + "'p:/_sync-projects/.local-nuget/'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + } + + if (!Path.IsPathRooted(localFeedPath)) + { + yield return new ValidationResult( + $"LocalFeedPath must be an absolute path. Received: '{localFeedPath}'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + + var root = Path.GetPathRoot(localFeedPath); + if (string.IsNullOrEmpty(root)) + { + yield return new ValidationResult( + $"LocalFeedPath could not be parsed. Received: '{localFeedPath}'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + + var lastChar = root[^1]; + if (lastChar == Path.DirectorySeparatorChar || lastChar == Path.AltDirectorySeparatorChar) + yield break; + + if (root.StartsWith(@"\\", StringComparison.Ordinal) || root.StartsWith("//", StringComparison.Ordinal)) + yield break; + + yield return new ValidationResult( + $"LocalFeedPath must be an absolute path (e.g. 'C:\\folder' or '\\\\server\\share'). Received: '{localFeedPath}'.", + [nameof(LocalFeedPath)] + ); + } +} \ No newline at end of file diff --git a/src/Purview.Build/Settings/ReleaseSettings.cs b/src/Purview.Build/Settings/ReleaseSettings.cs new file mode 100644 index 0000000..ef909c5 --- /dev/null +++ b/src/Purview.Build/Settings/ReleaseSettings.cs @@ -0,0 +1,25 @@ +namespace Purview.Build.Settings; + +public enum ReleaseMode +{ + None, + + NuGet, + + GitHubRelease, + + LocalNuGet, +} + +public sealed record ReleaseSettings +{ + public const string SectionName = "Release"; + + public ReleaseMode Mode { get; set; } = ReleaseMode.None; + + /// + /// When true, the GitHub release module uploads every file in Build:ArtifactsFolder + /// (for example .nupkg/.snupkg or .vsix) as release assets. + /// + public bool UploadArtifacts { get; init; } +} \ No newline at end of file diff --git a/src/Purview.Build/appsettings.json b/src/Purview.Build/appsettings.json new file mode 100644 index 0000000..2df1c8c --- /dev/null +++ b/src/Purview.Build/appsettings.json @@ -0,0 +1,40 @@ +{ + "Build": { + "Solution": "src/Product.slnx", + "Configuration": "Release", + "ArtifactsFolder": "artifacts", + "RunTests": true, + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "TestProjects": "*", + "TestFramework": "TUnit", + "TestFilter": "/*/*/*/*/", + "RunLint": true, + "RunPack": true, + "ValidatePack": true + }, + "PackValidation": { + "RequireSymbolPackage": true, + "RequireSymbolFiles": true, + "RequiredContent": {}, + "ForbiddenContent": {} + }, + "NuGet": { + "FeedUrl": "https://api.nuget.org/v3/index.json", + "TrustedPublishing": false + }, + "PublishLocalNuGet": { + "LocalFeedPath": "", + "OverwriteExistingPackages": true, + "ShutdownDotnetBuilderServer": true, + "ClearPackageCache": true + }, + "GitHub": { + "AccessToken": null, + "ProductHeader": "Purview.Build.Pipeline" + }, + "Release": { + "Mode": "None", + "UploadArtifacts": false + } +} \ No newline at end of file diff --git a/src/Purview.Build/packages.lock.json b/src/Purview.Build/packages.lock.json index e8c108e..5f70cc9 100644 --- a/src/Purview.Build/packages.lock.json +++ b/src/Purview.Build/packages.lock.json @@ -31,6 +31,15 @@ "ModularPipelines": "3.2.8" } }, + "ModularPipelines.Git": { + "type": "Direct", + "requested": "[3.2.8, 4.0.0)", + "resolved": "3.2.8", + "contentHash": "ZCVeF2QsjtihDHzdtf5mf4bE2r3Kv+cyj0oJ6yElTT9N9pPUOqgtXAVeabBE0aL/u04YFX95MABirCkhkUyrKQ==", + "dependencies": { + "ModularPipelines": "3.2.8" + } + }, "ModularPipelines.GitHub": { "type": "Direct", "requested": "[3.2.8, 4.0.0)", @@ -42,6 +51,18 @@ "Octokit": "14.0.0" } }, + "NuGet.Packaging": { + "type": "Direct", + "requested": "[7.9.0, 8.0.0)", + "resolved": "7.9.0", + "contentHash": "Q2jxuud5kJLV0rUzsC02iC5ts8QsQu9UVyMvF8lULY3TwHL/W/PrnQ9bQFvMR7jJkzF1uilRPI1o+7VOtc2XMg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.9.0", + "NuGet.Versioning": "7.9.0", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, "NuGet.Versioning": { "type": "Direct", "requested": "[7.9.0, 8.0.0)", @@ -441,14 +462,33 @@ "resolved": "10.0.6", "contentHash": "L8P21mqaG+CXvPheLndean/cHCOcItJqH8nx+0YQnK7wAiOR0G1IOC418ZSzTMD2D6Gmo0f2M5WR70XtpX2B8g==" }, - "ModularPipelines.Git": { + "Newtonsoft.Json": { "type": "Transitive", - "resolved": "3.2.8", - "contentHash": "ZCVeF2QsjtihDHzdtf5mf4bE2r3Kv+cyj0oJ6yElTT9N9pPUOqgtXAVeabBE0aL/u04YFX95MABirCkhkUyrKQ==", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "OWFEzHpwvgzSaHYmWzfbyTm6oBOy/BFSFEDk73zM9jiJaxdopYZ/DrqIQogaM3F+ZGTppBJffmzu5adetEI/YA==", "dependencies": { - "ModularPipelines": "3.2.8" + "NuGet.Frameworks": "7.9.0" } }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "oY9XZp+TWjOmiGmU4R4JWBRY3F4TAVo7tqPr/z7MOsFcEpdYHMryjNSscoLBxUTR3WmFmYgO0Gvu4eRbz6pzOg==", + "dependencies": { + "NuGet.Common": "7.9.0", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "w2adPmgUafmW9ON1MzDjuHmViN0vtiPV48Dz3OE6px8BiGSqrnWHOjwFEPOEPAZx20J1c7bxp90Hxo9l4xGWtw==" + }, "Octokit": { "type": "Transitive", "resolved": "14.0.0", @@ -525,6 +565,16 @@ "resolved": "10.0.6", "contentHash": "RMe4gRBwSVd1O6HVRjNwLgcH2jjrT8sHyNRJegZLX68voA+HzMf1xZPvFxMMDpyW86B9U2pYslgl4DFCE61WyA==" }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, "vertical-spectreconsolelogger": { "type": "Transitive", "resolved": "0.10.1-dev.20241201.35", diff --git a/testassets/version.json b/testassets/version.json deleted file mode 100644 index e63e9b8..0000000 --- a/testassets/version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "0.0.0-smoke" -}