chore(release): publish only the CLI + template to NuGet (source-ownership) #54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Backend CI | |
| # Backend pipeline. Only does real work when backend code changed (src/**, | |
| # central build props, global.json, coverage settings, or this file). A | |
| # client-only PR still triggers the workflow but every heavy job is skipped — | |
| # the always-running "Backend CI" gate job reports green so required status | |
| # checks resolve. SDK is pinned via global.json (GA, no preview channel). | |
| on: | |
| push: | |
| branches: [main] | |
| tags: ['v*'] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Package version (e.g., 10.0.0-rc.1)' | |
| required: false | |
| type: string | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true | |
| DOTNET_CLI_TELEMETRY_OPTOUT: true | |
| DOTNET_NOLOGO: true | |
| jobs: | |
| changes: | |
| name: Detect changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| # On any non-PR event (push to main, tags, manual release) run the | |
| # full pipeline. On PRs, run only when backend-relevant paths changed. | |
| backend: ${{ github.event_name != 'pull_request' || steps.filter.outputs.backend == 'true' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| backend: | |
| - 'src/**' | |
| - 'global.json' | |
| - 'coverage.runsettings' | |
| - '.github/workflows/backend.yml' | |
| test: | |
| name: Unit Tests | |
| needs: changes | |
| if: needs.changes.outputs.backend == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Restore | |
| run: dotnet restore src/FSH.Starter.slnx | |
| - name: Build | |
| run: dotnet build src/FSH.Starter.slnx -c Release --no-restore -warnaserror | |
| # Gate on DIRECT vulnerable packages (the ones we control). Transitive | |
| # advisories are surfaced but not blocking — they often can't be fixed | |
| # without an upstream bump. | |
| - name: Audit dependencies for known vulnerabilities | |
| run: | | |
| if dotnet list src/FSH.Starter.slnx package --vulnerable 2>&1 | tee vuln-direct.txt | grep -q 'has the following vulnerable'; then | |
| echo "::error::Direct package(s) with known vulnerabilities detected — see log." | |
| cat vuln-direct.txt | |
| exit 1 | |
| fi | |
| echo "No direct vulnerable packages." | |
| echo "::group::Transitive advisories (informational)" | |
| dotnet list src/FSH.Starter.slnx package --vulnerable --include-transitive 2>&1 | tee vuln-transitive.txt || true | |
| echo "::endgroup::" | |
| - name: Run unit tests with coverage | |
| run: | | |
| for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do | |
| echo "::group::${proj}.Tests" | |
| dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \ | |
| --collect:"XPlat Code Coverage" --settings coverage.runsettings \ | |
| --results-directory ./TestResults \ | |
| --logger "trx;LogFileName=${proj}.trx" | |
| echo "::endgroup::" | |
| done | |
| - name: Upload unit coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-unit | |
| path: ./TestResults/**/coverage.cobertura.xml | |
| retention-days: 1 | |
| - name: Upload unit test results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: test-results-unit | |
| path: '**/*.trx' | |
| retention-days: 7 | |
| integration-test: | |
| name: Integration Tests | |
| needs: changes | |
| if: needs.changes.outputs.backend == 'true' | |
| runs-on: ubuntu-latest | |
| # Testcontainers requires Docker — ubuntu-latest has it pre-installed. | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Restore | |
| run: dotnet restore src/FSH.Starter.slnx | |
| # Integration tests build a host with WebApplicationFactory + Testcontainers. | |
| # Middleware tests live in a separate assembly (own process) because a 2nd | |
| # production-middleware host resets the static ModuleLoader. | |
| - name: Run integration tests with coverage | |
| run: | | |
| dotnet test src/Tests/Integration.Tests -c Release \ | |
| --collect:"XPlat Code Coverage" --settings coverage.runsettings \ | |
| --results-directory ./TestResults --logger "trx;LogFileName=Integration.Tests.trx" | |
| dotnet test src/Tests/Integration.Middleware.Tests -c Release \ | |
| --collect:"XPlat Code Coverage" --settings coverage.runsettings \ | |
| --results-directory ./TestResults --logger "trx;LogFileName=Integration.Middleware.Tests.trx" | |
| - name: Upload integration coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-integration | |
| path: ./TestResults/**/coverage.cobertura.xml | |
| retention-days: 1 | |
| - name: Upload integration test results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: test-results-integration | |
| path: '**/*.trx' | |
| retention-days: 7 | |
| migrator-smoke: | |
| name: DbMigrator Container Smoke | |
| needs: changes | |
| if: needs.changes.outputs.backend == 'true' | |
| runs-on: ubuntu-latest | |
| # Catches container-publish regressions (Dockerfile-less SDK container) and DI-graph | |
| # breakage in the migrator. Publishes the image locally, runs `apply --catalog-only` | |
| # against an ephemeral Postgres, asserts exit 0. | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_PASSWORD: migrator_smoke_pwd | |
| POSTGRES_DB: fsh_migrator_smoke | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Publish DbMigrator container (local daemon) | |
| run: | | |
| dotnet publish src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj \ | |
| -c Release -r linux-x64 \ | |
| /t:PublishContainer \ | |
| -p:ContainerRepository=fsh-db-migrator \ | |
| -p:ContainerImageTags=smoke | |
| - name: Run DbMigrator against ephemeral Postgres | |
| run: | | |
| docker run --rm --network host \ | |
| -e DatabaseOptions__Provider=POSTGRESQL \ | |
| -e DatabaseOptions__ConnectionString="Host=localhost;Port=5432;Database=fsh_migrator_smoke;Username=postgres;Password=migrator_smoke_pwd" \ | |
| -e DatabaseOptions__MigrationsAssembly=FSH.Starter.Migrations.PostgreSQL \ | |
| fsh-db-migrator:smoke apply --catalog-only \ | |
| | tee migrator.log | |
| grep -q "finished successfully" migrator.log | |
| coverage: | |
| name: Coverage Gate | |
| needs: [changes, test, integration-test] | |
| if: needs.changes.outputs.backend == 'true' | |
| runs-on: ubuntu-latest | |
| # Merges the coverage already collected by the test + integration-test jobs | |
| # (no test re-runs) and fails if line coverage regresses below the floor. | |
| # Ratchet: bump MIN_LINE upward as coverage improves. | |
| env: | |
| MIN_LINE: '80' | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Download unit coverage | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: coverage-unit | |
| path: ./coverage-in/unit | |
| - name: Download integration coverage | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: coverage-integration | |
| path: ./coverage-in/integration | |
| - name: Install ReportGenerator | |
| run: | | |
| dotnet tool install --global dotnet-reportgenerator-globaltool | |
| echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" | |
| - name: Merge coverage and generate report | |
| run: reportgenerator -reports:"./coverage-in/**/coverage.cobertura.xml" -targetdir:./coverage-report -reporttypes:"TextSummary;Html;Cobertura" | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: coverage-report | |
| path: ./coverage-report | |
| retention-days: 7 | |
| - name: Enforce coverage floor | |
| run: | | |
| LINE=$(grep -oP 'Line coverage:\s*\K[0-9.]+' coverage-report/Summary.txt | head -1) | |
| echo "Line coverage: ${LINE}% (floor: ${MIN_LINE}%)" | |
| awk "BEGIN { exit !(${LINE} >= ${MIN_LINE}) }" || { echo "::error::Line coverage ${LINE}% is below the ${MIN_LINE}% floor"; exit 1; } | |
| publish-dev-containers: | |
| name: Publish Dev Containers | |
| needs: [changes, test, integration-test] | |
| if: needs.changes.outputs.backend == 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Publish API container image | |
| run: | | |
| dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ | |
| -c Release -r linux-x64 \ | |
| /t:PublishContainer \ | |
| -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ | |
| -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' | |
| publish-release: | |
| name: Publish Release (NuGet + Containers) | |
| needs: [changes, test, integration-test] | |
| if: | | |
| (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch') || | |
| startsWith(github.ref, 'refs/tags/v') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| global-json-file: global.json | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Determine version | |
| id: version | |
| run: | | |
| if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then | |
| VERSION="${{ github.event.inputs.version }}" | |
| elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then | |
| VERSION="${GITHUB_REF#refs/tags/v}" | |
| else | |
| echo "No version specified and not a tag push" | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "Publishing version: $VERSION" | |
| - name: Restore and Build with version | |
| run: | | |
| dotnet restore src/FSH.Starter.slnx | |
| dotnet build src/FSH.Starter.slnx -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} | |
| # Distribution model is source-ownership: consumers get the FULL source via the | |
| # `dotnet new fsh` template (below), NOT per-module/BuildingBlocks NuGet packages. | |
| # Only two artifacts publish to NuGet — the `fsh` global CLI tool and the template. | |
| # (The previous per-module packs were incomplete — 4 of 10 modules — and contradicted | |
| # the locked source-ownership model, so they were removed.) | |
| - name: Pack CLI Tool | |
| run: dotnet pack src/Tools/CLI/FSH.CLI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} | |
| # The dotnet new template package — the primary distribution artifact. Packs the | |
| # repo (with its root .template.config) so consumers can `dotnet new install` then | |
| # `dotnet new fsh -n MyApp`. Scaffolded output is fully owned, detached source. | |
| - name: Pack Template | |
| run: dotnet pack templates/FullStackHero.NET.StarterKit.csproj -c Release -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} | |
| - name: Push to NuGet.org | |
| run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push API container | |
| run: | | |
| dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ | |
| -c Release -r linux-x64 \ | |
| /t:PublishContainer \ | |
| -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ | |
| -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' | |
| # Single required status check. Always runs (even when the heavy jobs are | |
| # skipped on a client-only PR) so branch protection resolves. Fails only if a | |
| # job it depends on actually failed or was cancelled — skipped is fine. | |
| backend-ci: | |
| name: Backend CI | |
| needs: [changes, test, integration-test, migrator-smoke, coverage] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Fail if a required backend job failed | |
| if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} | |
| run: | | |
| echo "::error::A required backend job failed or was cancelled." | |
| exit 1 | |
| - name: Success | |
| run: echo "Backend CI passed." |