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 01/12] 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 02/12] 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 95c55047c3ec97a9e7608e96822fe65681d97e1a 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 15:46:41 +0100 Subject: [PATCH 03/12] ci: fix stale workflow paths in the solution file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable-workflow split left OpenFeature.slnx pointing at code-coverage.yml, dotnet-format.yml and e2e.yml, which no longer exist. Repoint them at their reusable-* successors and list the two remaining new workflows so the folder covers the whole CI surface. Also drops codeql-analysis.yml, a dead entry left behind when that workflow was deleted in #700, and adds dco-merge-group.yml, which was never listed. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- OpenFeature.slnx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/OpenFeature.slnx b/OpenFeature.slnx index e3f02767..5893bc91 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -22,12 +22,15 @@ - - - - + + + + + + + From 71fb5e954c0247a95d04571b71b1e08e902be381 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 16:28:23 +0100 Subject: [PATCH 04/12] ci: tighten permissions and checkout credentials in the reusable workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set persist-credentials: false on all six checkouts. Nothing runs git after checkout - the steps are dotnet restore/build/test/pack/push, a Codecov upload over HTTPS and a file copy - so leaving the job token in .git/config only widens the blast radius of a compromised step. The spec submodule is a public HTTPS URL, so recursive checkout is unaffected. Drop pull-requests: write from build, coverage, format and e2e. It was applied uniformly in #439 when permissions blocks were first added, without a per-job need. --report-github emits workflow commands and a step summary, and codecov-action uploads with its own token; the PR comment comes from the Codecov app, not the action. lint-pr and release keep theirs because they really do write to pull requests. Pass the packaging secret and version suffix through env instead of expanding them into the run body, so neither is materialised in the generated script. Also list the two composite actions in the solution file, matching the workflows folder already there. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ---- .github/workflows/reusable-aot.yml | 1 + .github/workflows/reusable-build.yml | 2 +- .github/workflows/reusable-coverage.yml | 2 +- .github/workflows/reusable-e2e.yml | 2 +- .github/workflows/reusable-format.yml | 3 ++- .github/workflows/reusable-packaging.yml | 9 +++++++-- OpenFeature.slnx | 4 ++++ 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89223809..88c229c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,6 @@ jobs: name: Build permissions: contents: read - pull-requests: write uses: ./.github/workflows/reusable-build.yml coverage: @@ -48,7 +47,6 @@ jobs: if: github.event_name != 'merge_group' permissions: contents: read - pull-requests: write uses: ./.github/workflows/reusable-coverage.yml secrets: codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} @@ -57,14 +55,12 @@ jobs: name: Format permissions: contents: read - pull-requests: write uses: ./.github/workflows/reusable-format.yml e2e: name: E2E permissions: contents: read - pull-requests: write uses: ./.github/workflows/reusable-e2e.yml aot: diff --git a/.github/workflows/reusable-aot.yml b/.github/workflows/reusable-aot.yml index 0e011ab5..9b177443 100644 --- a/.github/workflows/reusable-aot.yml +++ b/.github/workflows/reusable-aot.yml @@ -47,6 +47,7 @@ jobs: with: fetch-depth: 0 submodules: recursive + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index c7879198..68fa9004 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -12,7 +12,6 @@ jobs: permissions: contents: read - pull-requests: write strategy: matrix: @@ -26,6 +25,7 @@ jobs: with: fetch-depth: 0 submodules: recursive + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet diff --git a/.github/workflows/reusable-coverage.yml b/.github/workflows/reusable-coverage.yml index bd1be9d2..d76eeb7e 100644 --- a/.github/workflows/reusable-coverage.yml +++ b/.github/workflows/reusable-coverage.yml @@ -13,7 +13,6 @@ jobs: permissions: contents: read - pull-requests: write strategy: matrix: @@ -26,6 +25,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml index 8fe4f01d..e6c73527 100644 --- a/.github/workflows/reusable-e2e.yml +++ b/.github/workflows/reusable-e2e.yml @@ -9,7 +9,6 @@ jobs: permissions: contents: read - pull-requests: write runs-on: ubuntu-latest @@ -19,6 +18,7 @@ jobs: with: fetch-depth: 0 submodules: recursive + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet diff --git a/.github/workflows/reusable-format.yml b/.github/workflows/reusable-format.yml index 3f2d738f..6518694b 100644 --- a/.github/workflows/reusable-format.yml +++ b/.github/workflows/reusable-format.yml @@ -9,13 +9,14 @@ jobs: permissions: contents: read - pull-requests: write runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet diff --git a/.github/workflows/reusable-packaging.yml b/.github/workflows/reusable-packaging.yml index faa02a12..9adaa1db 100644 --- a/.github/workflows/reusable-packaging.yml +++ b/.github/workflows/reusable-packaging.yml @@ -19,6 +19,7 @@ jobs: with: fetch-depth: 0 submodules: recursive + persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet @@ -41,7 +42,9 @@ jobs: # 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 }}" + env: + VERSION_SUFFIX: ${{ steps.suffix.outputs.value }} + run: dotnet pack -c Release --no-restore --version-suffix "$VERSION_SUFFIX" # `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 @@ -50,7 +53,9 @@ jobs: # `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 + env: + NUGET_API_KEY: ${{ secrets.GITHUB_TOKEN }} + run: dotnet nuget push "src/**/*.nupkg" --api-key "$NUGET_API_KEY" --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 diff --git a/OpenFeature.slnx b/OpenFeature.slnx index 5893bc91..dee775bf 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -15,6 +15,10 @@ + + + + From 7225364d0a541146a963530e6abee4c6d063a75e 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 16:42:48 +0100 Subject: [PATCH 05/12] ci: fail the fork package upload when no packages are produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/upload-artifact defaults to if-no-files-found: warn, so a fork pull request that packed nothing would hand the contributor an empty nupkgs artifact and still report success. The artifact is the only way a fork gets the packages, and that path is rarely exercised, so a silent regression could sit unnoticed. Fail instead. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/reusable-packaging.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/reusable-packaging.yml b/.github/workflows/reusable-packaging.yml index 9adaa1db..a556033d 100644 --- a/.github/workflows/reusable-packaging.yml +++ b/.github/workflows/reusable-packaging.yml @@ -63,3 +63,7 @@ jobs: with: name: nupkgs path: src/**/*.nupkg + # The artifact is the only way a fork contributor gets the packages, so an empty + # upload should fail rather than warn: pack succeeded but produced nothing means + # something changed about packability, and it would otherwise go unnoticed. + if-no-files-found: error From 078e72510c01f1d109175e5017faca3303bc7864 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 18:39:07 +0100 Subject: [PATCH 06/12] Removed comments. 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 | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c229c0..5b916cec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,6 @@ on: merge_group: workflow_dispatch: -# 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 pull request from the @@ -77,13 +65,6 @@ jobs: packages: write uses: ./.github/workflows/reusable-packaging.yml - # The single required status check. Branch protection lists `CI Gate` and nothing else - # 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 - # skipped check as satisfied, which would let exactly the broken pull requests through. ci-gate: name: CI Gate if: always() From 2f4501212b8d5be4bdeda384472efe7e6878ac81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:57:40 +0100 Subject: [PATCH 07/12] ci: update coverage job comment to clarify merge queue requirements 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 | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b916cec..9bd3bfa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,7 @@ jobs: coverage: 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, 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`. + # Coverage isn't required for the merge queue. if: github.event_name != 'merge_group' permissions: contents: read From 2c5c7e734d44968dee4b5f40c6b66c81175687b4 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 09:30:40 +0100 Subject: [PATCH 08/12] Update lint-pr.yml 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/lint-pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 3778dbcd..b196d430 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -1,6 +1,8 @@ name: "Lint PR" on: + # Required for the merge queue mandatory check + merge_group: pull_request_target: types: - opened From 4e6d373cef69254bb157544cde0fc5b143f05439 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 09:51:52 +0100 Subject: [PATCH 09/12] Refactor PR lint workflow for merge group support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the linting workflow to include a separate job for merge group checks and adjusted the runner environment to ubuntu-slim. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index b196d430..e53d1aaa 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -12,7 +12,8 @@ on: jobs: main: name: Validate PR title - runs-on: ubuntu-latest + runs-on: ubuntu-slim + if: github.event_name == 'pull_request_target' permissions: pull-requests: write steps: @@ -42,3 +43,12 @@ jobs: with: header: pr-title-lint-error delete: true + + merge-group-pass: + name: PR title lint + if: github.event_name == 'merge_group' + runs-on: ubuntu-slim + + steps: + - name: Skip for merge queue + run: echo "PR title lint is not applicable to merge_group; passing required check." From be0f1771f7d3659afe54493f2aaa79295dc13f16 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 09:54:40 +0100 Subject: [PATCH 10/12] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index e53d1aaa..6d076e45 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -48,6 +48,7 @@ jobs: name: PR title lint if: github.event_name == 'merge_group' runs-on: ubuntu-slim + permissions: {} steps: - name: Skip for merge queue From 6380c4e5600e8fcdcd38d4231d7033f494789f58 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:06:10 +0100 Subject: [PATCH 11/12] Update lint-pr.yml 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/lint-pr.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 6d076e45..b653f810 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -17,7 +17,12 @@ jobs: permissions: pull-requests: write steps: + - name: Skip for merge queue + if: github.event_name == 'merge_group' + run: echo "PR title lint is not applicable to merge_group; passing required check." + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + if: github.event_name == 'pull_request_target' id: lint_pr_title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -25,7 +30,7 @@ jobs: - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 # When the previous steps fails, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. - if: always() && (steps.lint_pr_title.outputs.error_message != null) + if: always() && github.event_name == 'pull_request_target' && steps.lint_pr_title.outputs.error_message != null with: header: pr-title-lint-error message: | @@ -38,18 +43,8 @@ jobs: ${{ steps.lint_pr_title.outputs.error_message }} ``` # Delete a previous comment when the issue has been resolved - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + - if: github.event_name == 'pull_request_target' && steps.lint_pr_title.outputs.error_message == null uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: pr-title-lint-error delete: true - - merge-group-pass: - name: PR title lint - if: github.event_name == 'merge_group' - runs-on: ubuntu-slim - permissions: {} - - steps: - - name: Skip for merge queue - run: echo "PR title lint is not applicable to merge_group; passing required check." From 2ab706c0692c5382d113760ca06f25327fd8e6a9 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:16:23 +0100 Subject: [PATCH 12/12] Refactor Lint PR workflow configuration 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/lint-pr.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index b653f810..483d05b9 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -13,7 +13,6 @@ jobs: main: name: Validate PR title runs-on: ubuntu-slim - if: github.event_name == 'pull_request_target' permissions: pull-requests: write steps: