diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67a56b2..5180f6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,87 @@ -# Weekly dependency PRs. NuGet versions live in each csproj (no central package -# management), so Dependabot walks the whole tree rather than a manifest folder. version: 2 updates: + # --------------------------------------------------------------------------- + # NuGet packages (src + tests + demo) + # + # 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. + # --------------------------------------------------------------------------- - package-ecosystem: nuget directory: "/" schedule: interval: weekly day: monday - open-pull-requests-limit: 5 + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 commit-message: - prefix: "Bump" + prefix: "chore(deps)" labels: - dependencies + - nuget + # ------------------------------------------------------------------------- + # These two carry deliberate per-TFM floors (see the csproj comment): a + # net8.0 consumer must stay on its own 8.0.x servicing line, so an + # 8.x -> 10.x major PR is never mergeable and would just be weekly noise. + # + # The list is exactly this repo's per-TFM floors and nothing else + # (STANDARD.md 4.10). Auth additionally floors + # System.Security.Cryptography.ProtectedData; this package does not + # reference it, so naming it here would assert a dependency that is not in + # the tree. + # + # Two caveats, both inherent to Dependabot rather than to this repo: + # 1. `ignore` matches by dependency NAME and cannot be scoped to a single + # target framework. These packages are referenced under BOTH the net8.0 + # and net10.0 ItemGroups, so this also suppresses a future net10 major + # (10.x -> 11.x). Bump those by hand when a new .NET major lands. + # 2. `ignore` conditions filter SECURITY updates as well as version + # updates, so a major-version security fix for these would also be + # suppressed. Low risk in practice (a CVE fix for 8.0.x ships as + # 8.0.y, a patch), but worth knowing. + # ------------------------------------------------------------------------- + ignore: + - dependency-name: Microsoft.Extensions.DependencyInjection.Abstractions + update-types: + - version-update:semver-major + - dependency-name: Microsoft.Extensions.Http + update-types: + - version-update:semver-major + groups: + nuget-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + # --------------------------------------------------------------------------- + # GitHub Actions across all three workflows: checkout, setup-dotnet, cache, + # upload/download-artifact and NuGet/login in ci.yml, codeql-action in + # codeql.yml, fetch-metadata in dependabot-auto-merge.yml. Same grouping rule + # as NuGet. This is also what keeps action versions uniform across the estate + # (STANDARD.md 3.9). + # --------------------------------------------------------------------------- - package-ecosystem: github-actions directory: "/" schedule: interval: weekly day: monday - open-pull-requests-limit: 5 + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 commit-message: - prefix: "Bump" + 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 cca2feb..a9ff46c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,34 +1,65 @@ +# CI for NextIteration.SpectreConsole.SelfUpdate. +# Canonical shape defined in NextIteration.Standards STANDARD.md section 3 — change it +# there first, then here. +# +# 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. +# +# This file absorbed the former release.yml (STANDARD.md 3.0). The `publish` job now +# downloads the artifact this run's `build` job produced, so the bytes pushed to +# nuget.org are the exact bytes the gate saw. release.yml had to rebuild from the tag, +# publishing an artifact no gate had ever tested. +# +# nuget.org Trusted Publishing binds its policy to a specific workflow FILE. The policy +# for this package was repointed from release.yml to ci.yml as part of that fold; if +# publishing ever fails to authenticate, check that first. +# +# The test matrix runs all three platforms (STANDARD.md 3.1.1), and here that is not a +# formality: the library resolves an OS/arch RID token, picks a per-OS cache directory +# (AppData, ~/Library/Caches, XDG_CACHE_HOME), replaces a running executable, and takes +# an exclusive install lock whose FileShare.None semantics differ between POSIX and +# Windows. One test is deliberately POSIX-only and returns early on Windows — see the +# comment on InstallLockTests.Acquire_when_directory_not_writable_throws_not_writable. name: CI -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -# Build + test + pack on every push to main and every PR. Tag pushes are -# handled by .github/workflows/release.yml — that workflow runs the same -# build/test plus publishes to nuget.org. on: push: branches: [ main ] + 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 - + timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 - - name: Setup .NET - uses: actions/setup-dotnet@v6 + # Both SDKs: the shipping project targets 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 provides the runtime the net8.0 test leg executes on; the - # 10.0.x SDK does the building for both targets. 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 @@ -42,27 +73,91 @@ jobs: uses: actions/upload-artifact@v7 with: name: nuget-package - path: ./artifacts/*.nupkg + # Both .nupkg and .snupkg, so the publish job's glob also pushes symbols. + # release.yml globbed *.nupkg only and silently never published symbols. + path: ./artifacts/*nupkg test: strategy: + fail-fast: false # one platform failing must not hide another's result matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] - fail-fast: false + os: [ ubuntu-latest, windows-latest, macos-latest ] runs-on: ${{ matrix.os }} - + timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 - - name: Setup .NET - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@v6 with: - # 8.0.x provides the runtime the net8.0 test leg executes on; the - # 10.0.x SDK does the building for both targets. 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. - name: Test - run: dotnet test --configuration Release + run: dotnet test --configuration Release --verbosity normal + + # 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: ci + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + + permissions: + id-token: write # GitHub OIDC token issuance for NuGet trusted publishing + contents: read + + steps: + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - uses: actions/download-artifact@v8 + with: + name: nuget-package + path: ./artifacts + + # Exchanges the OIDC token for a short-lived (1h) nuget.org key. Requires a + # Trusted Publishing policy on nuget.org bound to this repo + THIS 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 }} + + - name: Publish to NuGet + 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/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2f60931..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Release - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -on: - push: - tags: - - 'v*' - -jobs: - publish: - runs-on: ubuntu-latest - - permissions: - id-token: write # required for NuGet trusted publishing (OIDC token issuance) - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - # 8.0.x provides the runtime the net8.0 test leg executes on; the - # 10.0.x SDK does the building for both targets. - dotnet-version: | - 8.0.x - 10.0.x - - - name: Restore - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test - run: dotnet test --configuration Release --no-build - - - name: Pack - run: dotnet pack --configuration Release --no-build --output ./artifacts - - - name: Upload package artifact - uses: actions/upload-artifact@v7 - with: - name: nuget-package - path: ./artifacts/*.nupkg - - # Exchange the GitHub OIDC token for a short-lived nuget.org API key. - # Requires a Trusted Publishing policy configured on nuget.org for this - # repo + release.yml workflow. NUGET_USER is your nuget.org profile name - # (not your email). The key is valid for 1 hour, so fetch it just before push. - - name: NuGet login (OIDC → short-lived API key) - uses: NuGet/login@v1 - id: nuget-login - with: - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ed6b4..7799271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [Unreleased] + +### Changed + +- **`release.yml` folded into `ci.yml`.** Publishing now happens in the same workflow run as the build, so the `publish` job pushes the artifact this run's `build` job produced — the exact bytes the gate tested. The old tag-triggered `release.yml` rebuilt from the tag and published an artifact no gate had ever seen. It also globbed `*.nupkg` when uploading, so the `.snupkg` was built and then silently never published; the glob is now `*nupkg` and symbols ship. Repointing the nuget.org Trusted Publishing policy from `release.yml` to `ci.yml` was part of the same change, because the policy is bound to a workflow filename. +- **CI now has a single aggregating gate job, `ci`, and it is the only required status check.** `build` and `test` were required directly before, which couples the branch ruleset to the matrix: `test`'s check names carry the matrix values, so adding or dropping a platform broke protection. The gate declares `needs: [build, test]` with `if: always()` and fails on any upstream result that is not success — including `skipped`, which branch protection would otherwise read as satisfied. +- **Every workflow declares `concurrency`, explicit `permissions`, and per-job `timeout-minutes`.** Superseded pushes cancel instead of stacking up, except on tags — a half-cancelled release can leave an incomplete package set on nuget.org. NuGet restore is cached on `~/.nuget/packages`. +- **`.github/dependabot.yml` rewritten.** Minor and patch bumps are grouped into one PR per ecosystem; majors are deliberately left ungrouped so each arrives separately and stays open for review. The two runtime-aligned packages carrying per-TFM floors (`Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Http`) are now under `ignore` for major updates, because an 8.x → 10.x bump on the net8 floor is never mergeable and was weekly noise. + +### Added + +- **CodeQL code scanning** (`codeql.yml`), weekly plus on every push and PR, with the `security-and-quality` query pack. The build is explicit rather than `autobuild`, which has been observed to pick a single TFM and silently analyse half a multi-targeted codebase. +- **Dependabot auto-merge for minor and patch bumps** (`dependabot-auto-merge.yml`), queued behind the `ci` gate. Majors are never auto-merged. Approval uses an `AUTO_MERGE_PAT` Dependabot secret owned by a code owner — an Actions secret of the same name resolves to an empty string in a Dependabot-triggered workflow, and a `GITHUB_TOKEN` approval cannot satisfy a code-owner review. + +None of the above changes the library, its public surface, or the package contents. + +--- + ## [0.3.1] — 2026-08-19 ### Changed