From 06418c2dfd8661e0d416f864f81b746a1f841768 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Thu, 20 Aug 2026 16:27:41 +0000 Subject: [PATCH] chore: adopt the standard CI shape, CodeQL and Dependabot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies NextIteration.Standards STANDARD.md sections 3 and 4 (workflow files and content), plus 2.6 and 2.7 for the test stack. PR A of the adoption sequence in ADOPTING.md. - ci.yml replaced with the canonical template: `build` / three-platform `test` matrix / `ci` gate / tag-gated `publish` (3.0, 3.0.1, 3.1, 3.1.1). Only difference from templates/.github/workflows/ci.yml is the `v*` tag glob and the repo-specific header comment, both permitted by 3.0.1. No EXCEPTIONS.md entry is needed for this repo. - The `ci` gate is the aggregating required check (3.1). `if: always()` is load-bearing: without it the gate is skipped when an upstream job fails, and branch protection reads a skipped check as satisfied. - Adds codeql.yml (4.4) and dependabot.yml + dependabot-auto-merge.yml (4.6, 4.7). Per-workflow concurrency, timeout-minutes and a NuGet restore cache close 3.5, 3.6 and 3.7. - dependabot.yml deliberately carries no `ignore` block: 4.10 scopes it to packages with per-TFM floors and this repo has none. Every dependency here is pre-1.0 and runtime-independent, so it takes a single common floor (1.5). - Adds Microsoft.Testing.Extensions.CodeCoverage (2.6). The canonical `test` job invokes it via `-- --coverage`, so the reference has to land with the workflow or the job fails on an unrecognised option. Verified locally: both TFMs emit a .coverage file. - Test project NoWarn extended to CA1515 and CA2007, and GenerateDocumentationFile set to false (2.7) ahead of PR C moving the documentation default into Directory.Build.props. Verified locally: Release build at zero warnings, 80 tests passing on both net8.0 and net10.0, coverage collected on each. Test matrix note: Windows and macOS have never run this suite. Nothing in the library touches the filesystem or an OS store, and hex formatting already goes through InvariantCulture, so no platform-specific defect is expected — but 3.1.1 is explicit that an untested platform is an unverified one, and CI is where that gets settled rather than asserted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 66 ++++++++ .github/workflows/ci.yml | 150 +++++++++++++----- .github/workflows/codeql.yml | 62 ++++++++ .github/workflows/dependabot-auto-merge.yml | 82 ++++++++++ CHANGELOG.md | 15 ++ Directory.Packages.props | 9 ++ ...eration.SpectreConsole.Splash.Tests.csproj | 20 ++- 7 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4d6f3b5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,66 @@ +version: 2 + +updates: + # --------------------------------------------------------------------------- + # NuGet packages (src + tests) + # + # Minor and patch bumps are grouped into a single PR so the auto-merge + # workflow has one unambiguous update-type to act on. Major bumps are + # deliberately left OUT of the group, so each arrives as its own PR and + # stays open for manual review. + # + # There is deliberately NO `ignore` block here. STANDARD.md 4.10 requires one + # entry per package carrying a per-TFM floor (STANDARD.md 1.4), and this repo + # has none: Figgle, Figgle.Fonts and Spectre.Console are all pre-1.0 and + # version independently of the .NET runtime, so they take a single common + # floor (1.5) and their majors are genuinely reviewable. An `ignore` entry for + # a package this repo does not floor per TFM would assert a policy that does + # not exist here -- which is why 4.6 is a structural clause and not a + # byte-identity one. Add entries here only if a runtime-aligned + # `Microsoft.Extensions.*`-style dependency is ever introduced. + # --------------------------------------------------------------------------- + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(deps)" + labels: + - dependencies + - nuget + groups: + nuget-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + + # --------------------------------------------------------------------------- + # GitHub Actions used by ci.yml (checkout, setup-dotnet, cache, upload/download + # artifact, NuGet/login) and codeql.yml. Same grouping rule as NuGet. + # --------------------------------------------------------------------------- + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(actions)" + labels: + - dependencies + - github-actions + groups: + actions-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65602c..6c5d9d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,42 +1,61 @@ +# CI for NextIteration.SpectreConsole.Splash. +# Canonical shape defined in NextIteration.Standards STANDARD.md section 3 — change it +# there first, then here. This file is the template verbatim bar the tag glob; there are +# no EXCEPTIONS.md entries for this repo. +# +# The single required status check is `ci`, the aggregating gate below. `build` and `test` +# must NOT be required directly: `test` is a matrix, so its check names carry the matrix +# values and change whenever the matrix does. The gate's name is stable. +# +# The test matrix runs all three platforms (STANDARD.md 3.1.1). Nothing here is +# platform-specific — the library writes no files and touches no OS store — but the +# rendered logo does depend on line-ending handling and on Spectre's console +# capability detection, and neither is verified by a Linux-only run. name: CI on: push: branches: [ main ] - tags: - - 'v*' + tags: [ 'v*' ] pull_request: branches: [ main ] +# Superseded pushes are cancelled. Tag builds are never cancelled — a half-cancelled +# release can leave an incomplete package set on nuget.org. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest - - permissions: - contents: read - + timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 - - name: Setup .NET - uses: actions/setup-dotnet@v6 + # Both SDKs: shipping projects target net8.0 and net10.0 and the tests run + # against BOTH (STANDARD.md 2.3), which needs the 8.0 runtime present. + - uses: actions/setup-dotnet@v6 with: - # 8.0.x supplies the runtime the net8.0 test run executes on; the - # build itself uses the newest installed SDK (10.0.x). dotnet-version: | 8.0.x 10.0.x + - uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}- + - name: Restore run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal - - name: Pack run: dotnet pack --configuration Release --no-build --output ./artifacts @@ -44,43 +63,102 @@ jobs: uses: actions/upload-artifact@v7 with: name: nuget-package - # Capture both .nupkg and .snupkg so the publish job's - # `dotnet nuget push *.nupkg` can also push the matching - # symbol package next to it. + # Both .nupkg and .snupkg, so the publish job's glob also pushes symbols. path: ./artifacts/*nupkg + test: + strategy: + fail-fast: false # one platform failing must not hide another's result + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}- + + # Tests run across every shipped TFM (STANDARD.md 2.3). No --no-build: this job + # does not share a filesystem with `build`, and rebuilding is cheaper and less + # fragile than shipping obj/ between jobs. + # `-- --coverage` passes through to Microsoft.Testing.Platform's coverage + # extension (STANDARD.md 2.6). Referencing a collector without invoking it is + # worse than none: it reads as coverage in the dependency list while producing + # no data. + - name: Test + run: dotnet test --configuration Release --verbosity normal -- --coverage + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-${{ matrix.os }} + path: '**/TestResults/*.coverage' + if-no-files-found: warn + + # THE required status check. Aggregates everything above so the ruleset never has to + # know the matrix shape. `if: always()` is essential — without it the gate is skipped + # when a dependency fails, and a skipped check reads as success to branch protection. + ci: + needs: [ build, test ] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify every required job succeeded + env: + RESULTS: ${{ join(needs.*.result, ',') }} + run: | + echo "upstream results: $RESULTS" + case "$RESULTS" in + *failure*|*cancelled*|*skipped*) + echo "::error title=CI gate::an upstream job did not succeed ($RESULTS)" + exit 1 ;; + esac + echo "all upstream jobs succeeded" + publish: - needs: build + needs: ci runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') permissions: - contents: read # actions/checkout — an explicit `permissions` block sets unlisted scopes to `none` - id-token: write # required for NuGet trusted publishing (OIDC token issuance) + id-token: write # GitHub OIDC token issuance for NuGet trusted publishing + contents: read steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' - - name: Download artifact - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8 with: name: nuget-package path: ./artifacts - # Exchange the GitHub OIDC token for a short-lived nuget.org API key. - # Requires a Trusted Publishing policy configured on nuget.org that - # matches this repo owner/name and the `ci.yml` workflow file. - - name: NuGet login (OIDC → temp API key) - id: nuget-login + # Exchanges the OIDC token for a short-lived (1h) nuget.org key. Requires a + # Trusted Publishing policy on nuget.org bound to this repo + workflow file. + # NUGET_USER is the nuget.org account name, not an email. + - name: NuGet login (OIDC to temporary API key) uses: NuGet/login@v1 + id: login with: - user: ${{ secrets.NUGET_USER }} # your nuget.org username (profile name), not your email + user: ${{ secrets.NUGET_USER }} - name: Publish to NuGet - run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + run: > + dotnet nuget push "./artifacts/*.nupkg" + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f7cad91 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,62 @@ +# CodeQL code scanning. See STANDARD.md section 4.4. +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Weekly, so a newly published query pack finds existing code even when + # nothing has been pushed. Offset off the hour to avoid the scheduling spike. + - cron: '37 4 * * 1' + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + security-events: write # required to upload results + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + # security-and-quality is broader than the default security-extended; + # these are small libraries, so the extra findings are affordable. + queries: security-and-quality + + # Explicit build rather than autobuild: these repos multi-target, and + # autobuild has picked a single TFM in the past, silently analysing half + # the code. Restore is separate so a restore failure is legible. + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:csharp" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..ce7ab4e --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,82 @@ +name: Dependabot auto-merge + +# Auto-merges Dependabot minor and patch bumps once CI passes. Major bumps are +# left untouched so they stay open for manual review. +# +# `on: pull_request` (not pull_request_target) is deliberate: pull_request_target +# would run with a write token in the base-repo context, which is the classic +# privilege-escalation footgun. On Dependabot pull_request events the GITHUB_TOKEN +# is read-only by default, and the `permissions:` block below grants the write +# scopes back. This is GitHub's documented recipe. +# +# --------------------------------------------------------------------------- +# REQUIRED SETUP: the `AUTO_MERGE_PAT` secret must be stored as a +# **Dependabot secret**, NOT an Actions secret: +# +# Settings -> Secrets and variables -> Dependabot -> New repository secret +# gh secret set AUTO_MERGE_PAT --app dependabot +# +# Workflows triggered by Dependabot events only receive Dependabot secrets; +# Actions secrets resolve to an empty string. The guard step below fails loudly +# if that happens rather than letting the approval silently no-op. +# +# The PAT must belong to a CODEOWNER (the main ruleset sets +# require_code_owner_review: true, and a GITHUB_TOKEN/bot approval cannot +# satisfy a code-owner review). Scope: fine-grained with +# "Pull requests: read and write" on this repo, or classic `repo`. +# --------------------------------------------------------------------------- +on: pull_request + +permissions: + contents: read + pull-requests: read + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + + steps: + - name: Verify AUTO_MERGE_PAT is present + env: + AUTO_MERGE_PAT: ${{ secrets.AUTO_MERGE_PAT }} + run: | + if [ -z "$AUTO_MERGE_PAT" ]; then + echo "::error title=Missing AUTO_MERGE_PAT::Store it as a *Dependabot* secret (gh secret set AUTO_MERGE_PAT --app dependabot). Actions secrets are not available to Dependabot-triggered workflows." + exit 1 + fi + + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # `--auto` does NOT merge immediately: it queues the merge behind the branch + # ruleset, so the required check must go green first. That check is `ci` and only + # `ci` (STANDARD.md 3.1) — an aggregating gate over `build` and `test`, so the + # matrix can be reshaped without touching the ruleset or this comment. + # If CI fails, the PR just stays open. + # + # The approval uses the PAT so it counts as a code-owner review. Because the + # ruleset also sets dismiss_stale_reviews_on_push and require_last_push_approval, + # a follow-up Dependabot force-push re-triggers this workflow (pull_request + # includes `synchronize`) and the PR is re-approved. + - name: Approve and enable auto-merge (minor + patch) + if: | + steps.meta.outputs.update-type == 'version-update:semver-minor' || + steps.meta.outputs.update-type == 'version-update:semver-patch' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT }} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" + + # No approval, no auto-merge — the PR stays open for a human. + - name: Leave major bumps open + if: steps.meta.outputs.update-type == 'version-update:semver-major' + env: + DEPS: ${{ steps.meta.outputs.dependency-names }} + run: | + echo "::notice title=Major version bump::${DEPS} is a major bump; leaving this PR open for manual review." diff --git a/CHANGELOG.md b/CHANGELOG.md index e122e8a..e7491dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Canonical CI shape, CodeQL and Dependabot** per + [NextIteration.Standards](https://github.com/StuartMeeks/NextIteration.Standards) + `STANDARD.md` section 3 and 4. `ci.yml` now splits into `build`, a three-platform + `test` matrix, an aggregating `ci` gate and a tag-gated `publish`; the gate is the + single required status check, so the matrix can be reshaped without touching branch + protection. Adds `codeql.yml` (§4.4), `dependabot.yml` (§4.6) and + `dependabot-auto-merge.yml` (§4.7), plus per-workflow `concurrency`, + `timeout-minutes` and a NuGet restore cache (§3.5–3.7). +- **Code coverage is collected** via `Microsoft.Testing.Extensions.CodeCoverage`, the + Microsoft.Testing.Platform equivalent of coverlet (§2.6). CI invokes it and uploads + the result per platform. Contributor-facing only; the collector is test-only and does + not reach the package. + ### Changed - **Test suite migrated to xUnit.net v3 (`xunit.v3` 4.0.0)** from `xunit` 2.9.3. diff --git a/Directory.Packages.props b/Directory.Packages.props index 8d91e1b..266a47c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,5 +12,14 @@ + + diff --git a/tests/NextIteration.SpectreConsole.Splash.Tests/NextIteration.SpectreConsole.Splash.Tests.csproj b/tests/NextIteration.SpectreConsole.Splash.Tests/NextIteration.SpectreConsole.Splash.Tests.csproj index 1447a3d..0a1b375 100644 --- a/tests/NextIteration.SpectreConsole.Splash.Tests/NextIteration.SpectreConsole.Splash.Tests.csproj +++ b/tests/NextIteration.SpectreConsole.Splash.Tests/NextIteration.SpectreConsole.Splash.Tests.csproj @@ -9,13 +9,29 @@ enable false true - - $(NoWarn);CA1707 + + false + + $(NoWarn);CA1707;CA1515;CA2007 + +