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/ci.yml b/.github/workflows/ci.yml index ca34e304..9bd3bfa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,106 +3,104 @@ name: CI on: push: branches: [main] - paths-ignore: - - "**.md" pull_request: branches: [main] - paths-ignore: - - "**.md" + merge_group: + workflow_dispatch: + +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 + # 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' }} + +permissions: + contents: read jobs: build: + name: Build permissions: contents: read - pull-requests: write - strategy: - matrix: - os: [ubuntu-latest, windows-latest] + uses: ./.github/workflows/reusable-build.yml - runs-on: ${{ matrix.os }} + coverage: + name: Coverage + # Coverage isn't required for the merge queue. + if: github.event_name != 'merge_group' + permissions: + contents: read + uses: ./.github/workflows/reusable-coverage.yml + secrets: + codecov-token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} - 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 }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - 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 + format: + name: Format + permissions: + contents: read + uses: ./.github/workflows/reusable-format.yml + + e2e: + name: E2E + permissions: + contents: read + uses: ./.github/workflows/reusable-e2e.yml + + aot: + name: AOT + permissions: + contents: read + uses: ./.github/workflows/reusable-aot.yml packaging: + name: Packaging needs: build - permissions: contents: read packages: write - id-token: write - attestations: write + uses: ./.github/workflows/reusable-packaging.yml + + ci-gate: + name: CI Gate + if: always() + needs: [build, coverage, format, e2e, aot, packaging] + + permissions: + contents: read runs-on: ubuntu-latest + timeout-minutes: 5 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 }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - 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: 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: Publish NuGet packages (base) - if: 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 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: nupkgs - path: src/**/*.nupkg + - name: Check that every gated job succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + # 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" + 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 + | 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/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 diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 3778dbcd..483d05b9 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 @@ -10,11 +12,16 @@ on: jobs: main: name: Validate PR title - runs-on: ubuntu-latest + runs-on: ubuntu-slim 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 }} @@ -22,7 +29,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: | @@ -35,7 +42,7 @@ 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 diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/reusable-aot.yml similarity index 72% rename from .github/workflows/aot-compatibility.yml rename to .github/workflows/reusable-aot.yml index d5ed0bb5..9b177443 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/reusable-aot.yml @@ -1,18 +1,15 @@ -name: AOT Compatibility +name: Reusable / AOT Compatibility on: - push: - branches: [main] - pull_request: - branches: [main] - merge_group: - workflow_dispatch: + workflow_call: jobs: - aot-compatibility: - name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }}) + aot: + name: ${{ matrix.runtime }} + permissions: contents: read + strategy: fail-fast: false matrix: @@ -50,20 +47,12 @@ jobs: with: fetch-depth: 0 submodules: recursive + persist-credentials: false - - 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 + - name: Setup .NET + uses: ./.github/actions/setup-dotnet 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- + cache-key-suffix: -${{ matrix.arch }} - name: Restore dependencies shell: pwsh diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml new file mode 100644 index 00000000..68fa9004 --- /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 + + 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 + persist-credentials: false + + - 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/code-coverage.yml b/.github/workflows/reusable-coverage.yml similarity index 67% rename from .github/workflows/code-coverage.yml rename to .github/workflows/reusable-coverage.yml index c11d5052..d76eeb7e 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/reusable-coverage.yml @@ -1,20 +1,19 @@ -name: Code Coverage +name: Reusable / Code Coverage on: - push: - branches: [main] - paths-ignore: - - "**.md" - pull_request: - branches: [main] - paths-ignore: - - "**.md" + workflow_call: + secrets: + codecov-token: + description: "Codecov upload token." + required: true jobs: - build-test-report: + coverage: + name: ${{ matrix.os }} + permissions: contents: read - pull-requests: write + strategy: matrix: os: [ubuntu-latest, windows-latest] @@ -22,22 +21,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + persist-credentials: false - - 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: Run Test run: dotnet test --coverlet --coverlet-output-format opencover --coverlet-exclude "[GitHubActionsTestLogger*]*" --ignore-exit-code 8 @@ -52,7 +43,7 @@ jobs: disable_search: true fail_ci_if_error: true verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + token: ${{ secrets.codecov-token }} # On Windows, avoid passing a glob that expands into multiple args - name: Find coverage reports (Windows) @@ -77,4 +68,4 @@ jobs: disable_search: true fail_ci_if_error: true verbose: true - token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + token: ${{ secrets.codecov-token }} diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml new file mode 100644 index 00000000..e6c73527 --- /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 + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + submodules: recursive + persist-credentials: false + + - 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/dotnet-format.yml b/.github/workflows/reusable-format.yml similarity index 59% rename from .github/workflows/dotnet-format.yml rename to .github/workflows/reusable-format.yml index 79ee5d76..6518694b 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/reusable-format.yml @@ -1,26 +1,25 @@ -name: dotnet format +name: Reusable / Format on: - push: - branches: [main] - pull_request: - branches: [main] + workflow_call: jobs: - check-format: - runs-on: ubuntu-latest + format: + name: dotnet format + permissions: contents: read - pull-requests: write + + runs-on: ubuntu-latest steps: - - name: Check out code + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - global-json-file: global.json + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet - name: dotnet format run: | diff --git a/.github/workflows/reusable-packaging.yml b/.github/workflows/reusable-packaging.yml new file mode 100644 index 00000000..a556033d --- /dev/null +++ b/.github/workflows/reusable-packaging.yml @@ -0,0 +1,69 @@ +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 + persist-credentials: false + + - 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 + 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 + # 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) + 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 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + 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 diff --git a/OpenFeature.slnx b/OpenFeature.slnx index e3f02767..dee775bf 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -15,6 +15,10 @@ + + + + @@ -22,12 +26,15 @@ - - - - + + + + + + +