From 04466b8fdd39fd1aba9a406826817b61dc14de97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:26:13 +0100 Subject: [PATCH 1/6] ci: consolidate PR checks behind a single required "CI Gate" job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `needs:` cannot reference jobs in another workflow file, so a single required status check means the gated jobs have to live in one workflow. Fold code-coverage.yml, dotnet-format.yml, aot-compatibility.yml and e2e.yml into ci.yml and add a `CI Gate` job that depends on all of them. Once branch protection requires only `DCO` + `CI Gate`, build, test, coverage, format, E2E and the AOT matrix all become effectively required, and matrix legs can be added or removed without editing branch protection. Along the way this fixes several things that were silently broken: - ci.yml, code-coverage.yml and dotnet-format.yml had no `merge_group` trigger, so the merge queue never validated build, test, coverage or format against the merged result. They now run there. - ci.yml and code-coverage.yml used workflow-level `paths-ignore: "**.md"`. A workflow skipped by path filtering reports no check runs at all, so a required check inside it would leave docs-only pull requests blocked forever. The filter is gone. - The gate uses `if: always()`. Without it the job inherits the implicit `success()` condition and is *skipped* when a dependency fails - and branch protection treats a skipped check as satisfied, which would let exactly the broken pull requests through. The jq predicate also fails on `cancelled` and on any skip that is not explicitly allow-listed. - `github.event.pull_request.head.repo.fork == false` is true on `push` and `merge_group` because GitHub coerces the missing value to 0 before comparing. Combined with `startsWith(github.ref, 'refs/heads/')`, which matches `refs/heads/gh-readonly-queue/*`, adding a merge_group trigger would have published a NuGet package for every queue entry. The packaging job now checks `github.event_name` explicitly, always packs as a validation step, and publishes only on push or a non-fork pull request. - Merge queue runs are never cancelled by the concurrency group; a cancelled run reports a non-success conclusion and ejects the pull request from the queue. Coverage is skipped on `merge_group` - the `gh-readonly-queue/*` ref is thrown away, so uploading it to Codecov is meaningless and `build` already runs the same tests there. It is the only entry in the gate's allow-list. The E2E job deliberately keeps the bare `e2e-tests` id so its check run name is unchanged; it is a required context today and a required check that stops reporting blocks every open pull request. Duplicated setup-dotnet and NuGet cache blocks move into a local composite action. Branch protection still has to be updated after this merges. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/actions/setup-dotnet/action.yml | 25 ++ .github/workflows/aot-compatibility.yml | 96 ------- .github/workflows/ci.yml | 324 +++++++++++++++++++++--- .github/workflows/code-coverage.yml | 80 ------ .github/workflows/dotnet-format.yml | 28 -- .github/workflows/e2e.yml | 43 ---- 6 files changed, 311 insertions(+), 285 deletions(-) create mode 100644 .github/actions/setup-dotnet/action.yml delete mode 100644 .github/workflows/aot-compatibility.yml delete mode 100644 .github/workflows/code-coverage.yml delete mode 100644 .github/workflows/dotnet-format.yml delete mode 100644 .github/workflows/e2e.yml diff --git a/.github/actions/setup-dotnet/action.yml b/.github/actions/setup-dotnet/action.yml new file mode 100644 index 00000000..8a425bc7 --- /dev/null +++ b/.github/actions/setup-dotnet/action.yml @@ -0,0 +1,25 @@ +name: "Setup .NET" +description: "Installs the .NET SDK pinned by global.json and restores the NuGet package cache" + +inputs: + cache-key-suffix: + description: "Extra qualifier appended to the NuGet cache key, e.g. the runner architecture" + required: false + default: "" + +runs: + using: "composite" + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}${{ inputs.cache-key-suffix }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} + restore-keys: | + ${{ runner.os }}${{ inputs.cache-key-suffix }}-nuget- + ${{ runner.os }}-nuget- diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml deleted file mode 100644 index d5ed0bb5..00000000 --- a/.github/workflows/aot-compatibility.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: AOT Compatibility - -on: - push: - branches: [main] - pull_request: - branches: [main] - merge_group: - workflow_dispatch: - -jobs: - aot-compatibility: - name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }}) - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - # Linux x64 - - os: ubuntu-latest - arch: x64 - runtime: linux-x64 - # Linux ARM64 - - os: ubuntu-24.04-arm - arch: arm64 - runtime: linux-arm64 - # Windows x64 - - os: windows-latest - arch: x64 - runtime: win-x64 - # Windows ARM64 - - os: windows-11-arm - arch: arm64 - runtime: win-arm64 - # macOS x64 - - os: macos-15-intel - arch: x64 - runtime: osx-x64 - # macOS ARM64 (Apple Silicon) - - os: macos-latest - arch: arm64 - runtime: osx-arm64 - - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - global-json-file: global.json - - - name: Cache NuGet packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.arch }}-nuget- - ${{ runner.os }}-nuget- - - - name: Restore dependencies - shell: pwsh - run: dotnet restore - - - name: Build solution - shell: pwsh - run: dotnet build -c Release --no-restore - - - name: Test AOT compatibility project build - shell: pwsh - run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore - - - name: Publish AOT compatibility test (cross-platform) - shell: pwsh - run: | - dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` - -f net10.0 ` - -r ${{ matrix.runtime }} ` - -o ./aot-output - - - name: Run AOT compatibility test - shell: pwsh - run: | - if ("${{ runner.os }}" -eq "Windows") { - ./aot-output/OpenFeature.AotCompatibility.exe - } else { - chmod +x ./aot-output/OpenFeature.AotCompatibility - ./aot-output/OpenFeature.AotCompatibility - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca34e304..b0515af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,18 +3,33 @@ name: CI on: push: branches: [main] - paths-ignore: - - "**.md" pull_request: branches: [main] - paths-ignore: - - "**.md" + merge_group: + workflow_dispatch: + +# Every job that produces a required status check lives in this workflow, so it must +# never be filtered out. A workflow skipped by `paths`/`paths-ignore` reports no check +# runs at all, and a required check that never reports blocks the pull request forever +# and times the entry out of the merge queue. + +concurrency: + # Supersede in-flight runs for the same pull request, but never cancel a merge queue + # run: a cancelled run reports a non-success conclusion and ejects the PR from the queue. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read jobs: build: + name: Build & Test (${{ matrix.os }}) + permissions: contents: read pull-requests: write + strategy: matrix: os: [ubuntu-latest, windows-latest] @@ -28,18 +43,8 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - global-json-file: global.json - - - name: Cache NuGet packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- + - name: Setup .NET + uses: ./.github/actions/setup-dotnet - name: Restore run: dotnet restore @@ -54,17 +59,159 @@ jobs: run: | dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj - packaging: - needs: build + coverage: + name: Code Coverage (${{ matrix.os }}) + + # Coverage is a reporting concern, not a merge gate. The `gh-readonly-queue/*` ref a + # merge group builds is thrown away, so uploading it to Codecov is meaningless noise, + # and `build` already runs the same tests on the same platforms inside the queue. + # `ci-gate` allows this job to be skipped (see `allowed-skips`). + if: github.event_name != 'merge_group' permissions: contents: read - packages: write - id-token: write - attestations: write + pull-requests: write + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Run Test + run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 + + # Keep globbing on non-Windows runners (works today) + - name: Upload coverage to Codecov (non-Windows) + if: runner.os != 'Windows' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + name: Code Coverage for ${{ matrix.os }} + files: "**/TestResults/**/coverage.opencover.*.xml" + disable_search: true + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + + # On Windows, avoid passing a glob that expands into multiple args + - name: Find coverage reports (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $files = Get-ChildItem -Path "$PWD" -Recurse -Filter "coverage.opencover.*.xml" | ForEach-Object { $_.FullName } + if (-not $files -or $files.Count -eq 0) { + throw "No coverage.opencover.*.xml files were found under the repository." + } + + # Codecov accepts comma-separated file paths + $csv = ($files -join ",") + "CODECOV_FILES=$csv" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Upload coverage to Codecov (Windows) + if: runner.os == 'Windows' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + name: Code Coverage for ${{ matrix.os }} + files: ${{ env.CODECOV_FILES }} + disable_search: true + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + + format: + name: Format + + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: dotnet format + run: | + # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012 + dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050 + + # Deliberately *not* given a friendly `name:`. `e2e-tests` is currently a required status + # check on `main` and `v1`, and a required context that stops reporting blocks every open + # pull request. Keeping the check run name identical lets this change merge before branch + # protection is switched over to `CI Gate`. Safe to rename once that switch has happened. + e2e-tests: + permissions: + contents: read + pull-requests: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Initialize Tests + run: cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ + + - name: Run Tests + run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github + + aot: + name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }}) + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + include: + # Linux x64 + - os: ubuntu-latest + arch: x64 + runtime: linux-x64 + # Linux ARM64 + - os: ubuntu-24.04-arm + arch: arm64 + runtime: linux-arm64 + # Windows x64 + - os: windows-latest + arch: x64 + runtime: win-x64 + # Windows ARM64 + - os: windows-11-arm + arch: arm64 + runtime: win-arm64 + # macOS x64 + - os: macos-15-intel + arch: x64 + runtime: osx-x64 + # macOS ARM64 (Apple Silicon) + - os: macos-latest + arch: arm64 + runtime: osx-arm64 + + runs-on: ${{ matrix.os }} + steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -72,37 +219,138 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + - name: Setup .NET + uses: ./.github/actions/setup-dotnet with: - global-json-file: global.json + cache-key-suffix: -${{ matrix.arch }} + + - name: Restore dependencies + shell: pwsh + run: dotnet restore + + - name: Build solution + shell: pwsh + run: dotnet build -c Release --no-restore + + - name: Test AOT compatibility project build + shell: pwsh + run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore + + - name: Publish AOT compatibility test (cross-platform) + shell: pwsh + run: | + dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` + -f net10.0 ` + -r ${{ matrix.runtime }} ` + -o ./aot-output + + - name: Run AOT compatibility test + shell: pwsh + run: | + if ("${{ runner.os }}" -eq "Windows") { + ./aot-output/OpenFeature.AotCompatibility.exe + } else { + chmod +x ./aot-output/OpenFeature.AotCompatibility + ./aot-output/OpenFeature.AotCompatibility + } + + packaging: + name: Packaging + needs: build + + permissions: + contents: read + packages: write + + runs-on: ubuntu-latest - - name: Cache NuGet packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet - name: Restore run: dotnet restore - - name: Pack NuGet packages (CI versions) - if: startsWith(github.ref, 'refs/heads/') - run: dotnet pack -c Release --no-restore --version-suffix "ci.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" + - name: Compute version suffix + id: suffix + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + push) prefix=ci ;; + pull_request) prefix=pr ;; + *) prefix=mq ;; + esac + echo "value=${prefix}.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" >> "$GITHUB_OUTPUT" - - name: Pack NuGet packages (PR versions) - if: startsWith(github.ref, 'refs/pull/') - run: dotnet pack -c Release --no-restore --version-suffix "pr.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" + - name: Pack NuGet packages + run: dotnet pack -c Release --no-restore --version-suffix "${{ steps.suffix.outputs.value }}" + # `github.event.pull_request` is absent outside `pull_request`, and GitHub coerces a + # null to 0 when comparing against a boolean, so `... .fork == false` is true on + # `push` and `merge_group` too. The event name must be checked explicitly, otherwise + # every merge queue entry would publish a package for a ref that is thrown away. - name: Publish NuGet packages (base) - if: github.event.pull_request.head.repo.fork == false + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.GITHUB_TOKEN }}" --source https://nuget.pkg.github.com/open-feature/index.json - name: Publish NuGet packages (fork) - if: github.event.pull_request.head.repo.fork == true + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nupkgs path: src/**/*.nupkg + + # The single required status check. Branch protection lists `CI Gate` and nothing else + # from this workflow, so matrix legs can be added, removed or renamed without silently + # detaching branch protection. + # + # `if: always()` is mandatory. Without it this job inherits the implicit `success()` + # condition, so a failing dependency makes it *skipped* — and branch protection treats a + # skipped check as satisfied, which would let exactly the broken pull requests through. + ci-gate: + name: CI Gate + if: always() + needs: [build, coverage, format, e2e-tests, aot, packaging] + + permissions: + contents: read + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check that every gated job succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + # Jobs that are allowed to report `skipped`. Anything else that skips fails the + # gate, so an accidental `if:` cannot quietly stop gating a job. + ALLOWED_SKIPS: coverage + run: | + echo "### CI Gate" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Job | Result |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" + echo "$NEEDS_JSON" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"' >> "$GITHUB_STEP_SUMMARY" + + failing=$(echo "$NEEDS_JSON" | jq -r --arg allowed "$ALLOWED_SKIPS" ' + ($allowed | split(",") | map(select(length > 0))) as $ok + | to_entries[] + | select(.value.result != "success") + | select(.value.result != "skipped" or ([.key] - $ok | length) > 0) + | "\(.key): \(.value.result)" + ') + + if [ -n "$failing" ]; then + echo "::error::One or more gated jobs did not succeed:" + echo "$failing" + exit 1 + fi + + echo "All gated jobs succeeded or were intentionally skipped." diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml deleted file mode 100644 index c11d5052..00000000 --- a/.github/workflows/code-coverage.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Code Coverage - -on: - push: - branches: [main] - paths-ignore: - - "**.md" - pull_request: - branches: [main] - paths-ignore: - - "**.md" - -jobs: - build-test-report: - permissions: - contents: read - pull-requests: write - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - global-json-file: global.json - - - name: Cache NuGet packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Run Test - run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 - - # Keep globbing on non-Windows runners (works today) - - name: Upload coverage to Codecov (non-Windows) - if: runner.os != 'Windows' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - name: Code Coverage for ${{ matrix.os }} - files: "**/TestResults/**/coverage.opencover.*.xml" - disable_search: true - fail_ci_if_error: true - verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} - - # On Windows, avoid passing a glob that expands into multiple args - - name: Find coverage reports (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = Get-ChildItem -Path "$PWD" -Recurse -Filter "coverage.opencover.*.xml" | ForEach-Object { $_.FullName } - if (-not $files -or $files.Count -eq 0) { - throw "No coverage.opencover.*.xml files were found under the repository." - } - - # Codecov accepts comma-separated file paths - $csv = ($files -join ",") - "CODECOV_FILES=$csv" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Upload coverage to Codecov (Windows) - if: runner.os == 'Windows' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - name: Code Coverage for ${{ matrix.os }} - files: ${{ env.CODECOV_FILES }} - disable_search: true - fail_ci_if_error: true - verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml deleted file mode 100644 index 79ee5d76..00000000 --- a/.github/workflows/dotnet-format.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: dotnet format - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - check-format: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - - steps: - - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - global-json-file: global.json - - - name: dotnet format - run: | - # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012 - dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 5aed21c6..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: E2E Test - -on: - pull_request: - types: - - opened - - synchronize - - reopened - branches: - - main - merge_group: - -jobs: - e2e-tests: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - global-json-file: global.json - - - name: Cache NuGet packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Initialize Tests - run: | - git submodule update --init --recursive - cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ - - - name: Run Tests - run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github From 95af77bdafba3a7d9b88fa5580517ba8874ee09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:03:40 +0100 Subject: [PATCH 2/6] ci: split the gated jobs into reusable workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the CI Gate consolidation. Rather than one long ci.yml, each unit of work becomes a `workflow_call` building block and ci.yml is reduced to an orchestrator that wires them together and evaluates the gate. ci.yml build -> reusable-build.yml coverage -> reusable-coverage.yml format -> reusable-format.yml e2e -> reusable-e2e.yml aot -> reusable-aot.yml packaging -> reusable-packaging.yml ci-gate -- the single required status check The gate still has to live in ci.yml because `needs:` cannot reference a job in another workflow file. Everything else moves out. Beyond readability, actionlint cross-validates the calls, so a typo in an input or secret name becomes a lint error rather than a runtime failure. The blocks take no inputs. They have exactly one caller in this repository, so parameters would only ever be passed their own defaults. The one thing that does have to cross the boundary is the Codecov token: secrets are not visible to a called workflow unless they are passed explicitly. One consequence worth calling out: check runs produced through a reusable workflow are named ` / `, so the checks are now `Build / ubuntu-latest`, `AOT / linux-arm64`, `Format / dotnet format` and so on. That means the `e2e-tests` context cannot be preserved the way it was in the previous commit, and branch protection has to drop it before this merges rather than after. `CI Gate` is unaffected - it is a plain job in ci.yml, so its name has no prefix and stays stable as jobs move around underneath it. Reusable workflows are `workflow_call`-only so they never self-trigger, and concurrency stays in the caller alone - sharing a group between caller and callee makes a run cancel itself. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 312 ++++------------------- .github/workflows/reusable-aot.yml | 84 ++++++ .github/workflows/reusable-build.yml | 43 ++++ .github/workflows/reusable-coverage.yml | 71 ++++++ .github/workflows/reusable-e2e.yml | 30 +++ .github/workflows/reusable-format.yml | 26 ++ .github/workflows/reusable-packaging.yml | 60 +++++ 7 files changed, 358 insertions(+), 268 deletions(-) create mode 100644 .github/workflows/reusable-aot.yml create mode 100644 .github/workflows/reusable-build.yml create mode 100644 .github/workflows/reusable-coverage.yml create mode 100644 .github/workflows/reusable-e2e.yml create mode 100644 .github/workflows/reusable-format.yml create mode 100644 .github/workflows/reusable-packaging.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0515af2..89223809 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,23 @@ on: merge_group: workflow_dispatch: -# Every job that produces a required status check lives in this workflow, so it must -# never be filtered out. A workflow skipped by `paths`/`paths-ignore` reports no check -# runs at all, and a required check that never reports blocks the pull request forever -# and times the entry out of the merge queue. +# This workflow owns the only required status check (`CI Gate`), so it must never be +# filtered out. A workflow skipped by `paths`/`paths-ignore` reports no check runs at all, +# and a required check that never reports blocks the pull request forever and eventually +# times the entry out of the merge queue. +# +# `needs:` cannot reference a job in a different workflow file, which is why every gated +# job is called from here. The work itself lives in the `reusable-*.yml` building blocks. +# Those have to sit directly in `.github/workflows/` - GitHub does not support reusable +# workflows in subdirectories, and a `./.github/workflows//.yml` reference fails +# at run time rather than being caught by a linter. Hence the filename prefix instead of a +# folder. concurrency: - # Supersede in-flight runs for the same pull request, but never cancel a merge queue - # run: a cancelled run reports a non-success conclusion and ejects the PR from the queue. + # Supersede in-flight runs for the same pull request, but never cancel a merge queue run: + # a cancelled run reports a non-success conclusion and ejects the pull request from the + # queue. Deliberately not set in the called workflows - sharing a group between caller and + # callee makes a run cancel itself. group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -24,300 +33,65 @@ permissions: jobs: build: - name: Build & Test (${{ matrix.os }}) - + name: Build permissions: contents: read pull-requests: write - - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Restore - run: dotnet restore - - - name: Build - run: dotnet build -c Release --no-restore - - - name: Test - run: dotnet test -c Release --no-build --report-github --ignore-exit-code 8 - - - name: aot-publish test - run: | - dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj + uses: ./.github/workflows/reusable-build.yml coverage: - name: Code Coverage (${{ matrix.os }}) - + name: Coverage # Coverage is a reporting concern, not a merge gate. The `gh-readonly-queue/*` ref a - # merge group builds is thrown away, so uploading it to Codecov is meaningless noise, - # and `build` already runs the same tests on the same platforms inside the queue. - # `ci-gate` allows this job to be skipped (see `allowed-skips`). + # merge group builds is thrown away, so uploading it to Codecov is meaningless, and + # `build` already runs the same tests on the same platforms inside the queue. + # This is the only job `ci-gate` is allowed to see as `skipped`. if: github.event_name != 'merge_group' - permissions: contents: read pull-requests: write - - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Run Test - run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 - - # Keep globbing on non-Windows runners (works today) - - name: Upload coverage to Codecov (non-Windows) - if: runner.os != 'Windows' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - name: Code Coverage for ${{ matrix.os }} - files: "**/TestResults/**/coverage.opencover.*.xml" - disable_search: true - fail_ci_if_error: true - verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} - - # On Windows, avoid passing a glob that expands into multiple args - - name: Find coverage reports (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = Get-ChildItem -Path "$PWD" -Recurse -Filter "coverage.opencover.*.xml" | ForEach-Object { $_.FullName } - if (-not $files -or $files.Count -eq 0) { - throw "No coverage.opencover.*.xml files were found under the repository." - } - - # Codecov accepts comma-separated file paths - $csv = ($files -join ",") - "CODECOV_FILES=$csv" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Upload coverage to Codecov (Windows) - if: runner.os == 'Windows' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - name: Code Coverage for ${{ matrix.os }} - files: ${{ env.CODECOV_FILES }} - disable_search: true - fail_ci_if_error: true - verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + uses: ./.github/workflows/reusable-coverage.yml + secrets: + codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} format: name: Format - permissions: contents: read pull-requests: write + uses: ./.github/workflows/reusable-format.yml - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: dotnet format - run: | - # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012 - dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050 - - # Deliberately *not* given a friendly `name:`. `e2e-tests` is currently a required status - # check on `main` and `v1`, and a required context that stops reporting blocks every open - # pull request. Keeping the check run name identical lets this change merge before branch - # protection is switched over to `CI Gate`. Safe to rename once that switch has happened. - e2e-tests: + e2e: + name: E2E permissions: contents: read pull-requests: write - - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Initialize Tests - run: cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ - - - name: Run Tests - run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github + uses: ./.github/workflows/reusable-e2e.yml aot: - name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }}) - + name: AOT permissions: contents: read - - strategy: - fail-fast: false - matrix: - include: - # Linux x64 - - os: ubuntu-latest - arch: x64 - runtime: linux-x64 - # Linux ARM64 - - os: ubuntu-24.04-arm - arch: arm64 - runtime: linux-arm64 - # Windows x64 - - os: windows-latest - arch: x64 - runtime: win-x64 - # Windows ARM64 - - os: windows-11-arm - arch: arm64 - runtime: win-arm64 - # macOS x64 - - os: macos-15-intel - arch: x64 - runtime: osx-x64 - # macOS ARM64 (Apple Silicon) - - os: macos-latest - arch: arm64 - runtime: osx-arm64 - - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - cache-key-suffix: -${{ matrix.arch }} - - - name: Restore dependencies - shell: pwsh - run: dotnet restore - - - name: Build solution - shell: pwsh - run: dotnet build -c Release --no-restore - - - name: Test AOT compatibility project build - shell: pwsh - run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore - - - name: Publish AOT compatibility test (cross-platform) - shell: pwsh - run: | - dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` - -f net10.0 ` - -r ${{ matrix.runtime }} ` - -o ./aot-output - - - name: Run AOT compatibility test - shell: pwsh - run: | - if ("${{ runner.os }}" -eq "Windows") { - ./aot-output/OpenFeature.AotCompatibility.exe - } else { - chmod +x ./aot-output/OpenFeature.AotCompatibility - ./aot-output/OpenFeature.AotCompatibility - } + uses: ./.github/workflows/reusable-aot.yml packaging: name: Packaging needs: build - permissions: contents: read packages: write - - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Restore - run: dotnet restore - - - name: Compute version suffix - id: suffix - env: - EVENT_NAME: ${{ github.event_name }} - run: | - case "$EVENT_NAME" in - push) prefix=ci ;; - pull_request) prefix=pr ;; - *) prefix=mq ;; - esac - echo "value=${prefix}.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" >> "$GITHUB_OUTPUT" - - - name: Pack NuGet packages - run: dotnet pack -c Release --no-restore --version-suffix "${{ steps.suffix.outputs.value }}" - - # `github.event.pull_request` is absent outside `pull_request`, and GitHub coerces a - # null to 0 when comparing against a boolean, so `... .fork == false` is true on - # `push` and `merge_group` too. The event name must be checked explicitly, otherwise - # every merge queue entry would publish a package for a ref that is thrown away. - - name: Publish NuGet packages (base) - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.GITHUB_TOKEN }}" --source https://nuget.pkg.github.com/open-feature/index.json - - - name: Publish NuGet packages (fork) - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: nupkgs - path: src/**/*.nupkg + uses: ./.github/workflows/reusable-packaging.yml # The single required status check. Branch protection lists `CI Gate` and nothing else - # from this workflow, so matrix legs can be added, removed or renamed without silently - # detaching branch protection. + # from this workflow, so jobs and matrix legs can be added, removed or renamed without + # silently detaching branch protection. # # `if: always()` is mandatory. Without it this job inherits the implicit `success()` - # condition, so a failing dependency makes it *skipped* — and branch protection treats a + # condition, so a failing dependency makes it *skipped* - and branch protection treats a # skipped check as satisfied, which would let exactly the broken pull requests through. ci-gate: name: CI Gate if: always() - needs: [build, coverage, format, e2e-tests, aot, packaging] + needs: [build, coverage, format, e2e, aot, packaging] permissions: contents: read @@ -329,15 +103,17 @@ jobs: - name: Check that every gated job succeeded env: NEEDS_JSON: ${{ toJSON(needs) }} - # Jobs that are allowed to report `skipped`. Anything else that skips fails the - # gate, so an accidental `if:` cannot quietly stop gating a job. + # Jobs allowed to report `skipped`. Anything else that skips fails the gate, so an + # accidental `if:` cannot quietly stop gating a job. ALLOWED_SKIPS: coverage run: | - echo "### CI Gate" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Job | Result |" >> "$GITHUB_STEP_SUMMARY" - echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" - echo "$NEEDS_JSON" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"' >> "$GITHUB_STEP_SUMMARY" + { + echo "### CI Gate" + echo "" + echo "| Job | Result |" + echo "| --- | --- |" + echo "$NEEDS_JSON" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"' + } >> "$GITHUB_STEP_SUMMARY" failing=$(echo "$NEEDS_JSON" | jq -r --arg allowed "$ALLOWED_SKIPS" ' ($allowed | split(",") | map(select(length > 0))) as $ok diff --git a/.github/workflows/reusable-aot.yml b/.github/workflows/reusable-aot.yml new file mode 100644 index 00000000..0e011ab5 --- /dev/null +++ b/.github/workflows/reusable-aot.yml @@ -0,0 +1,84 @@ +name: Reusable / AOT Compatibility + +on: + workflow_call: + +jobs: + aot: + name: ${{ matrix.runtime }} + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + include: + # Linux x64 + - os: ubuntu-latest + arch: x64 + runtime: linux-x64 + # Linux ARM64 + - os: ubuntu-24.04-arm + arch: arm64 + runtime: linux-arm64 + # Windows x64 + - os: windows-latest + arch: x64 + runtime: win-x64 + # Windows ARM64 + - os: windows-11-arm + arch: arm64 + runtime: win-arm64 + # macOS x64 + - os: macos-15-intel + arch: x64 + runtime: osx-x64 + # macOS ARM64 (Apple Silicon) + - os: macos-latest + arch: arm64 + runtime: osx-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + cache-key-suffix: -${{ matrix.arch }} + + - name: Restore dependencies + shell: pwsh + run: dotnet restore + + - name: Build solution + shell: pwsh + run: dotnet build -c Release --no-restore + + - name: Test AOT compatibility project build + shell: pwsh + run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore + + - name: Publish AOT compatibility test (cross-platform) + shell: pwsh + run: | + dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` + -f net10.0 ` + -r ${{ matrix.runtime }} ` + -o ./aot-output + + - name: Run AOT compatibility test + shell: pwsh + run: | + if ("${{ runner.os }}" -eq "Windows") { + ./aot-output/OpenFeature.AotCompatibility.exe + } else { + chmod +x ./aot-output/OpenFeature.AotCompatibility + ./aot-output/OpenFeature.AotCompatibility + } diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml new file mode 100644 index 00000000..c7879198 --- /dev/null +++ b/.github/workflows/reusable-build.yml @@ -0,0 +1,43 @@ +name: Reusable / Build & Test + +# Callable building block for ci.yml. `workflow_call` is the only trigger on purpose: a +# reusable workflow that also triggered on its own would run twice for every event. + +on: + workflow_call: + +jobs: + build: + name: ${{ matrix.os }} + + permissions: + contents: read + pull-requests: write + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test -c Release --no-build --report-github --ignore-exit-code 8 + + - name: aot-publish test + run: dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj diff --git a/.github/workflows/reusable-coverage.yml b/.github/workflows/reusable-coverage.yml new file mode 100644 index 00000000..bd1be9d2 --- /dev/null +++ b/.github/workflows/reusable-coverage.yml @@ -0,0 +1,71 @@ +name: Reusable / Code Coverage + +on: + workflow_call: + secrets: + codecov-token: + description: "Codecov upload token." + required: true + +jobs: + coverage: + name: ${{ matrix.os }} + + permissions: + contents: read + pull-requests: write + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Run Test + run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 + + # Keep globbing on non-Windows runners (works today) + - name: Upload coverage to Codecov (non-Windows) + if: runner.os != 'Windows' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + name: Code Coverage for ${{ matrix.os }} + files: "**/TestResults/**/coverage.opencover.*.xml" + disable_search: true + fail_ci_if_error: true + verbose: true + token: ${{ secrets.codecov-token }} + + # On Windows, avoid passing a glob that expands into multiple args + - name: Find coverage reports (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $files = Get-ChildItem -Path "$PWD" -Recurse -Filter "coverage.opencover.*.xml" | ForEach-Object { $_.FullName } + if (-not $files -or $files.Count -eq 0) { + throw "No coverage.opencover.*.xml files were found under the repository." + } + + # Codecov accepts comma-separated file paths + $csv = ($files -join ",") + "CODECOV_FILES=$csv" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Upload coverage to Codecov (Windows) + if: runner.os == 'Windows' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + name: Code Coverage for ${{ matrix.os }} + files: ${{ env.CODECOV_FILES }} + disable_search: true + fail_ci_if_error: true + verbose: true + token: ${{ secrets.codecov-token }} diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml new file mode 100644 index 00000000..8fe4f01d --- /dev/null +++ b/.github/workflows/reusable-e2e.yml @@ -0,0 +1,30 @@ +name: Reusable / E2E Tests + +on: + workflow_call: + +jobs: + e2e: + name: dotnet test + + permissions: + contents: read + pull-requests: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Initialize Tests + run: cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ + + - name: Run Tests + run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github diff --git a/.github/workflows/reusable-format.yml b/.github/workflows/reusable-format.yml new file mode 100644 index 00000000..3f2d738f --- /dev/null +++ b/.github/workflows/reusable-format.yml @@ -0,0 +1,26 @@ +name: Reusable / Format + +on: + workflow_call: + +jobs: + format: + name: dotnet format + + permissions: + contents: read + pull-requests: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: dotnet format + run: | + # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012 + dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050 diff --git a/.github/workflows/reusable-packaging.yml b/.github/workflows/reusable-packaging.yml new file mode 100644 index 00000000..faa02a12 --- /dev/null +++ b/.github/workflows/reusable-packaging.yml @@ -0,0 +1,60 @@ +name: Reusable / Packaging + +on: + workflow_call: + +jobs: + packaging: + name: dotnet pack + + permissions: + contents: read + packages: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Restore + run: dotnet restore + + - name: Compute version suffix + id: suffix + env: + EVENT_NAME: ${{ github.event_name }} + run: | + case "$EVENT_NAME" in + push) prefix=ci ;; + pull_request) prefix=pr ;; + *) prefix=mq ;; + esac + echo "value=${prefix}.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" >> "$GITHUB_OUTPUT" + + # Always pack, even when nothing is published: a broken pack is a real build break + # and should fail the gate rather than only surfacing at release time. + - name: Pack NuGet packages + run: dotnet pack -c Release --no-restore --version-suffix "${{ steps.suffix.outputs.value }}" + + # `github.event.pull_request` is absent outside `pull_request`, and GitHub coerces the + # missing value to 0 when comparing against a boolean, so `... .fork == false` is also + # true on `push` and `merge_group`. The event name must therefore be checked + # explicitly, otherwise every merge queue entry would publish a package for a + # `gh-readonly-queue/*` ref that is thrown away moments later. + - name: Publish NuGet packages (base) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) + run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.GITHUB_TOKEN }}" --source https://nuget.pkg.github.com/open-feature/index.json + + - name: Publish NuGet packages (fork) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nupkgs + path: src/**/*.nupkg From 79d25c6a1ee49d872d74d9e91d93fc8ec7c523ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:53:14 +0100 Subject: [PATCH 3/6] ci: use MTP GitHub Actions report and upload coverage to GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--report-github` switch used in CI is not a real option. Neither Microsoft.Testing.Platform, xUnit v3, coverlet.MTP nor the .NET SDK register it, so it was silently ignored and no annotations or job summary were ever produced. GitHubActionsTestLogger was dead weight too: it is a VSTest logger, and this repo runs tests through Microsoft Testing Platform, which never loads it. Replace both with the first-party Microsoft.Testing.Extensions.GitHubActionsReport extension and its documented `--report-gh` switch (MTP 2.3.0+). This gives per-assembly log groups, failure/skip annotations and a Markdown job summary. The extension is scoped to test projects so it does not turn the benchmark and AOT sample projects into test applications. It is only published as a prerelease today, so the version is pinned and should be moved to the stable release once one ships. Also add GitHub's built-in code coverage alongside Codecov. Coverlet now emits Cobertura next to OpenCover, the per-project/per-TFM reports are merged into one file with ReportGenerator, and paths are rewritten to be repository-relative so coverage maps onto the diff. Codecov keeps using the unchanged OpenCover reports. The upload is best effort for now, since it needs Code Quality enabled on the repository. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + .github/workflows/reusable-build.yml | 2 +- .github/workflows/reusable-coverage.yml | 31 ++++++++++++++++++++++++- .github/workflows/reusable-e2e.yml | 2 +- CONTRIBUTING.md | 2 +- Directory.Packages.props | 2 +- build/Common.tests.props | 4 ++-- 7 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89223809..448f9ded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: permissions: contents: read pull-requests: write + code-quality: write uses: ./.github/workflows/reusable-coverage.yml secrets: codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index c7879198..39e80076 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -37,7 +37,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Test - run: dotnet test -c Release --no-build --report-github --ignore-exit-code 8 + run: dotnet test -c Release --no-build --report-gh --ignore-exit-code 8 - name: aot-publish test run: dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj diff --git a/.github/workflows/reusable-coverage.yml b/.github/workflows/reusable-coverage.yml index bd1be9d2..2ef5f8e2 100644 --- a/.github/workflows/reusable-coverage.yml +++ b/.github/workflows/reusable-coverage.yml @@ -14,6 +14,7 @@ jobs: permissions: contents: read pull-requests: write + code-quality: write strategy: matrix: @@ -26,12 +27,14 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + # Check out the PR head commit (not the merge commit) so coverage line numbers map to the diff + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup .NET uses: ./.github/actions/setup-dotnet - name: Run Test - run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 + run: dotnet test --coverlet --coverlet-output-format opencover cobertura --ignore-exit-code 8 # Keep globbing on non-Windows runners (works today) - name: Upload coverage to Codecov (non-Windows) @@ -69,3 +72,29 @@ jobs: fail_ci_if_error: true verbose: true token: ${{ secrets.codecov-token }} + + # GitHub's code coverage API takes a single Cobertura file, so merge the per-project/per-TFM reports. + # Paths are rewritten to be repository-relative so coverage maps onto the files in the diff. + - name: Merge Cobertura reports (Linux) + if: runner.os == 'Linux' + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.5.11 + export PATH="$PATH:$HOME/.dotnet/tools" + reportgenerator \ + -reports:"**/coverage.cobertura.*.xml" \ + -targetdir:"coverage-report" \ + -reporttypes:Cobertura \ + -filefilters:"-**/obj/**" + sed -i -e "s#${GITHUB_WORKSPACE}/##g" \ + -e "s#[^<]*#.#g" \ + coverage-report/Cobertura.xml + + - name: Upload coverage to GitHub (Linux) + if: runner.os == 'Linux' + uses: actions/upload-code-coverage@1c15be36fc3733ba839b1dd643bd9556e4426dc1 # v1.4.1 + with: + file: coverage-report/Cobertura.xml + language: C# + label: code-coverage/coverlet + # Best effort: a coverage-service outage must never block CI. Codecov remains the gate. + fail-on-error: false diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml index 8fe4f01d..453932d7 100644 --- a/.github/workflows/reusable-e2e.yml +++ b/.github/workflows/reusable-e2e.yml @@ -27,4 +27,4 @@ jobs: run: cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ - name: Run Tests - run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github + run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-gh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 951a89f0..f47c6139 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ dotnet test test/OpenFeature.Tests/ To run unit tests with code coverage execute: ```bash -dotnet test test/OpenFeature.Tests/ --coverlet --coverlet-output-format opencover +dotnet test test/OpenFeature.Tests/ --coverlet --coverlet-output-format opencover cobertura ``` #### E2E tests diff --git a/Directory.Packages.props b/Directory.Packages.props index c2c9fe1a..5e2d41e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,10 +35,10 @@ - + diff --git a/build/Common.tests.props b/build/Common.tests.props index 054ad94d..2c1b3635 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -20,8 +20,8 @@ - - + + From a9312e28ca7bbcd04adcb0ce9d0ffbca86227a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:50:56 +0100 Subject: [PATCH 4/6] Update CI workflow permissions for code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bd3bfa1..a647869d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: if: github.event_name != 'merge_group' permissions: contents: read + code-quality: write + pull-requests: write uses: ./.github/workflows/reusable-coverage.yml secrets: codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} From 69c22e1a45ade8fe0aad0a2dc188565c61f0c9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:09:24 +0100 Subject: [PATCH 5/6] Fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/reusable-build.yml | 2 +- .github/workflows/reusable-e2e.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 68fa9004..e459d858 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -37,7 +37,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Test - run: dotnet test -c Release --no-build --report-github --ignore-exit-code 8 + run: dotnet test -c Release --no-build --report-gh --ignore-exit-code 8 - name: aot-publish test run: dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml index e6c73527..76d4e193 100644 --- a/.github/workflows/reusable-e2e.yml +++ b/.github/workflows/reusable-e2e.yml @@ -27,4 +27,4 @@ jobs: run: cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ - name: Run Tests - run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-github + run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --report-gh From f3762077fa2beeb45a46d0069e85393286868d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:19:36 +0100 Subject: [PATCH 6/6] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/reusable-coverage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a647869d..eb1c67ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: permissions: contents: read code-quality: write - pull-requests: write + pull-requests: read uses: ./.github/workflows/reusable-coverage.yml secrets: codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} diff --git a/.github/workflows/reusable-coverage.yml b/.github/workflows/reusable-coverage.yml index f81db52a..60d142bc 100644 --- a/.github/workflows/reusable-coverage.yml +++ b/.github/workflows/reusable-coverage.yml @@ -13,7 +13,7 @@ jobs: permissions: contents: read - pull-requests: write + pull-requests: read code-quality: write strategy: