Repository navigation
Refine deploy condition in GitHub Actions workflow #704
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: UAuth CI | |
| on: | |
| push: | |
| branches: | |
| - dev | |
| - 'release/**' | |
| tags: | |
| - 'v*' | |
| pull_request: | |
| workflow_dispatch: | |
| jobs: | |
| frameworks: | |
| name: Framework Validation | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.policy.outputs.matrix }} | |
| latest: ${{ steps.policy.outputs.latest }} | |
| latest-display: ${{ steps.policy.outputs.latest-display }} | |
| sdks: ${{ steps.policy.outputs.sdks }} | |
| solution-frameworks: ${{ steps.policy.outputs.solution-frameworks }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Audit projects and generate compatibility matrix | |
| id: policy | |
| env: | |
| DOTNET_NOLOGO: '1' | |
| shell: bash | |
| run: | | |
| python3 - <<'PYTHON' | |
| import concurrent.futures | |
| import json | |
| import os | |
| from pathlib import Path | |
| import re | |
| import subprocess | |
| import xml.etree.ElementTree as ET | |
| def properties(project, names): | |
| return json.loads(subprocess.check_output( | |
| ["dotnet", "msbuild", str(project), "-nologo", "-getProperty:" + ",".join(names)], | |
| text=True))["Properties"] | |
| config = properties("src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj", | |
| ["UltimateAuthTargetFrameworks", "UltimateAuthLatestTargetFramework"]) | |
| frameworks = config["UltimateAuthTargetFrameworks"].split(";") | |
| assert frameworks and all(re.fullmatch(r"net\d+\.\d+", f) for f in frameworks), "Invalid supported frameworks" | |
| version = lambda f: tuple(map(int, f[3:].split("."))) | |
| assert frameworks == sorted(set(frameworks), key=version), "Frameworks must be unique and ascending" | |
| latest = config["UltimateAuthLatestTargetFramework"] | |
| assert latest == max(frameworks, key=version), "Incorrect latest test target" | |
| projects = sorted(p for base in ("src", "samples", "apps", "docs", "tests") | |
| for p in Path(base).rglob("*.csproj") if not {"obj", "bin"}.intersection(p.parts)) | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: | |
| metadata = dict(zip(projects, pool.map(lambda p: properties( | |
| p, ["TargetFramework", "TargetFrameworks", "IsPackable"]), projects))) | |
| targets = lambda info: (info["TargetFrameworks"] or info["TargetFramework"]).split(";") | |
| solution = {p.attrib["Path"].replace("\\", "/") for p in ET.parse("UltimateAuth.slnx").iter("Project")} | |
| assert {p.as_posix() for p in projects} <= solution, "Projects are missing from the full solution build" | |
| expected = {p for p in projects if p.parts[0] == "src" and "utilities" not in p.parts | |
| and p.parent.name != "CodeBeam.UltimateAuth.Client.JsMinifier"} | |
| expected.add(Path("samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj")) | |
| packages = {p: info for p, info in metadata.items() if info["IsPackable"].lower() == "true"} | |
| assert expected <= packages.keys(), "An expected library is missing or no longer packable" | |
| for p in expected: | |
| assert targets(packages[p]) == frameworks, f"Missing supported library target: {p}" | |
| tests = [p for p in projects if p.parts[0] == "tests"] | |
| assert tests, "No test projects discovered" | |
| for p in tests: | |
| assert targets(metadata[p]) == [latest], f"Tests must target only {latest}: {p}" | |
| required = {latest} | {info["TargetFramework"] for info in metadata.values() if info["TargetFramework"]} | |
| assert required <= set(frameworks), "Retarget single-target exceptions before dropping their library framework" | |
| matrix = {"include": []} | |
| for framework in frameworks: | |
| included = [p.as_posix() for p, info in packages.items() if framework in targets(info)] | |
| assert included, f"No packages selected for {framework}" | |
| matrix["include"].append({"framework": framework, "display": framework[3:].removesuffix(".0"), | |
| "sdk": framework[3:] + ".x", | |
| "projects": "\n".join(included)}) | |
| for p, info in packages.items(): | |
| assert set(targets(info)) <= set(frameworks), f"Uncovered package framework: {p}" | |
| with open(os.environ["GITHUB_OUTPUT"], "a") as output: | |
| output.write("matrix=" + json.dumps(matrix, separators=(",", ":")) + "\n") | |
| output.write("latest=" + latest + "\n") | |
| output.write("latest-display=" + latest[3:].removesuffix(".0") + "\n") | |
| output.write("solution-frameworks=" + "%3B".join(sorted(required, key=version)) + "\n") | |
| output.write("sdks<<SDKS\n" + "\n".join(f[3:] + ".x" for f in sorted(required, key=version)) + "\nSDKS\n") | |
| with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: | |
| summary.write("## Package/framework coverage\n\n| Project | Frameworks |\n|---|---|\n") | |
| for p, info in packages.items(): | |
| summary.write(f"| `{p}` | {', '.join(targets(info))} |\n") | |
| summary.write(f"\nRegular tests: `{latest}` ({len(tests)} projects, one run each).\n") | |
| PYTHON | |
| build-compatibility: | |
| name: Build (.NET ${{ matrix.display }}) | |
| needs: frameworks | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.frameworks.outputs.matrix) }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup matching SDK | |
| id: dotnet | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ matrix.sdk }} | |
| - name: Select matching SDK | |
| env: | |
| SDK_VERSION: ${{ steps.dotnet.outputs.dotnet-version }} | |
| run: | | |
| python3 - <<'PYTHON' | |
| import json, os | |
| from pathlib import Path | |
| path = Path('global.json') | |
| config = json.loads(path.read_text()) | |
| config['sdk'] = {'version': os.environ['SDK_VERSION'], 'rollForward': 'disable'} | |
| path.write_text(json.dumps(config, indent=2) + '\n') | |
| PYTHON | |
| dotnet --version | |
| - name: Build every applicable package | |
| env: | |
| PROJECTS: ${{ matrix.projects }} | |
| FRAMEWORK: ${{ matrix.framework }} | |
| run: | | |
| while IFS= read -r project; do | |
| dotnet build "$project" --configuration Release --framework "$FRAMEWORK" \ | |
| -p:UltimateAuthBuildTargetFrameworks="$FRAMEWORK" | |
| done <<< "$PROJECTS" | |
| build-test-coverage: | |
| name: Tests & Coverage (.NET ${{ needs.frameworks.outputs.latest-display }}) | |
| needs: frameworks | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: 📥 Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: 🧰 Setup .NET SDKs | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ needs.frameworks.outputs.sdks }} | |
| - name: 📦 Restore dependencies | |
| run: dotnet restore -p:UltimateAuthBuildTargetFrameworks=${{ needs.frameworks.outputs.solution-frameworks }} | |
| - name: 🏗️ Build | |
| run: dotnet build --configuration Release --no-restore -p:UltimateAuthBuildTargetFrameworks=${{ needs.frameworks.outputs.solution-frameworks }} | |
| - name: 🧪 Test with coverage | |
| run: | | |
| test_status=0 | |
| for project in CodeBeam.UltimateAuth.Tests.Unit CodeBeam.UltimateAuth.Tests.Integration CodeBeam.UltimateAuth.Tests.Integration.EfCore CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility; do | |
| dotnet test --project "tests/$project/$project.csproj" \ | |
| --configuration Release \ | |
| --framework "${{ needs.frameworks.outputs.latest }}" \ | |
| --no-build \ | |
| --coverlet \ | |
| --coverlet-output-format cobertura \ | |
| --report-trx \ | |
| --results-directory "TestResults/Framework/$project" || test_status=1 | |
| done | |
| exit "$test_status" | |
| - name: Upload framework test and coverage results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: framework-test-results | |
| path: TestResults/Framework | |
| - name: Upload coverage reports to Codecov | |
| if: always() | |
| uses: codecov/codecov-action@v5 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| slug: CodeBeamOrg/UltimateAuth | |
| flags: framework | |
| directory: TestResults/Framework | |
| identity-lab-security: | |
| name: Identity Lab / Tests & Coverage | |
| needs: frameworks | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Setup required SDKs | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ needs.frameworks.outputs.sdks }} | |
| - name: Run Identity Lab tests with coverage | |
| run: >- | |
| dotnet test --project tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj | |
| --configuration Release | |
| --framework ${{ needs.frameworks.outputs.latest }} | |
| -p:UltimateAuthBuildTargetFrameworks=${{ needs.frameworks.outputs.solution-frameworks }} | |
| --coverlet | |
| --coverlet-output-format cobertura | |
| --coverlet-output-format json | |
| --coverlet-include "[CodeBeam.UltimateAuth.IdentityLab]*" | |
| --coverlet-exclude-by-file "**/Migrations/**/*.cs" | |
| --report-trx | |
| --report-trx-filename identity-lab.trx | |
| --results-directory TestResults/IdentityLab | |
| - name: Generate Identity Lab coverage summary | |
| if: always() | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import glob | |
| import os | |
| import xml.etree.ElementTree as ET | |
| reports = glob.glob( | |
| "TestResults/IdentityLab/**/coverage.cobertura*.xml", | |
| recursive=True | |
| ) | |
| summary_path = os.environ["GITHUB_STEP_SUMMARY"] | |
| with open(summary_path, "a", encoding="utf-8") as output: | |
| output.write("## Identity Lab Coverage\n\n") | |
| if not reports: | |
| output.write("Coverage report was not generated.\n") | |
| raise SystemExit(0) | |
| reports = [ | |
| path for path in reports | |
| if "/In/" not in path.replace("\\", "/") | |
| ] | |
| if not reports: | |
| output.write("No usable coverage report found.\n") | |
| raise SystemExit(0) | |
| report = ET.parse(reports[0]).getroot() | |
| line_rate = float(report.attrib.get("line-rate", 0)) | |
| branch_rate = float(report.attrib.get("branch-rate", 0)) | |
| lines_valid = int(report.attrib.get("lines-valid", 0)) | |
| lines_covered = int(report.attrib.get("lines-covered", 0)) | |
| branches_valid = int(report.attrib.get("branches-valid", 0)) | |
| branches_covered = int(report.attrib.get("branches-covered", 0)) | |
| output.write("| Metric | Coverage |\n") | |
| output.write("|---|---:|\n") | |
| output.write(f"| Lines | {line_rate:.2%} |\n") | |
| output.write(f"| Branches | {branch_rate:.2%} |\n") | |
| output.write( | |
| f"| Covered lines | {lines_covered:,} / {lines_valid:,} |\n" | |
| ) | |
| output.write( | |
| f"| Covered branches | {branches_covered:,} / {branches_valid:,} |\n" | |
| ) | |
| output.write("\n") | |
| output.write( | |
| "Coverage is reported separately from UltimateAuth " | |
| "framework coverage.\n" | |
| ) | |
| PY | |
| - name: Upload Identity Lab test and coverage results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: identity-lab-test-results | |
| path: TestResults/IdentityLab | |