diff --git a/.editorconfig b/.editorconfig index 87607c1..9c7fecc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -37,6 +37,11 @@ indent_size = 2 end_of_line = crlf indent_size = 2 +# Workflow YAML is LF: Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of +# mixed. git still leaves endings alone (`* -text`); this and CI (editorconfig-checker) enforce it. Other YAML is CRLF. +[.github/workflows/*.{yml,yaml}] +end_of_line = lf + # JSON and JSONC files [*.{json,jsonc}] end_of_line = crlf diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 0000000..e019960 --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,10 @@ +{ + "Disable": { + "Charset": true, + "Indentation": true, + "IndentSize": true, + "TrimTrailingWhitespace": true, + "InsertFinalNewline": true, + "MaxLineLength": true + } +} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 004125b..3fc3a7d 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -1,238 +1,238 @@ -name: Build project release task - -# The single build/version/release task, called by publish-release (real publish) and test-pull-request -# (smoke). NBGV runs once in get-version and threads the version to every consumer. github.ref_name -# decides everything: main builds Release and a stable version, every other branch Debug and a prerelease. -# On a real publish the build job pushes to NuGet via OIDC trusted publishing (no API key) and hands the -# package to the github-release job. Permissions are granted by the caller (this task declares none) so -# the smoke caller, which grants only read, does not startup-fail on a write-scoped job. - -env: - PROJECT_FILE: ./Utilities/Utilities.csproj - -on: - workflow_call: - inputs: - # Create the GitHub release and push to NuGet. Off for smoke. - publish: - required: false - type: boolean - default: false - # Build and test only, never publish or upload. Forces every publish step off regardless of publish. - smoke: - required: false - type: boolean - default: false - secrets: - # nuget.org profile name for OIDC trusted publishing. Not needed for smoke. - NUGET_USERNAME: - required: false - -jobs: - - get-version: - name: Get version information job - runs-on: ubuntu-latest - outputs: - SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} - AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} - AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} - GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - # Default checkout: the triggering commit on its real branch. NBGV classifies from GITHUB_REF - # directly. fetch-depth: 0 gives NBGV the full history for the version height. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - - # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot - # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@master - - # Entry gate: assert branch and version classification agree before any build, so a misclassification - # fails fast. main must be a public release (no prerelease '-'), and every other branch must carry one. - validate-release: - name: Validate release version job - needs: [get-version] - runs-on: ubuntu-latest - steps: - - name: Validate branch and version consistency step - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - REF_NAME: ${{ github.ref_name }} - SMOKE: ${{ inputs.smoke }} - run: | - set -euo pipefail - # Smoke never publishes, so the publish-time branch/version gate does not apply. Smoke also runs - # on branches whose classification will not match an eventual publish target (a feature-branch - # smoke reads prerelease and would trip the main arm of a main-base PR), so skip it on smoke. - if [[ "$SMOKE" == "true" ]]; then - echo "Smoke build; skipping release version validation." - exit 0 - fi - # Strip +buildmetadata. Only a '-' in the core/prerelease segment marks a prerelease. - CORE_AND_PRE="${SEMVER2%%+*}" - if [[ "$REF_NAME" == "main" ]]; then - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - elif [[ "$CORE_AND_PRE" != *-* ]]; then - echo "::error::Prerelease ($REF_NAME) version '$SEMVER2' has no prerelease suffix; refusing to publish." - exit 1 - fi - - build: - name: Build NuGet library job - needs: [get-version, validate-release] - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - # Pin to the versioned commit so the package and the release tag match even if the branch advances - # mid-run. This is a compile checkout only, NBGV already ran in get-version. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - # Build the library and its packages into publish/ (both output paths) so they archive together. - - name: Build library step - run: | - set -euo pipefail - dotnet build ${{ env.PROJECT_FILE }} \ - --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ - -property:OutputPath=${{ runner.temp }}/publish/ \ - -property:PackageOutputPath=${{ runner.temp }}/publish/ \ - -property:Version=${{ needs.get-version.outputs.SemVer2 }} \ - -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ - -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \ - -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ - -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - - # OIDC trusted publishing: trade the GitHub OIDC token for a short-lived (1-hour) NuGet key, so there - # is no stored API key. Needs id-token: write from the caller. - - name: NuGet login step - if: ${{ inputs.publish && !inputs.smoke }} - id: nuget-login - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 - with: - user: ${{ secrets.NUGET_USERNAME }} - - # Pushing the .nupkg also pushes the co-located .snupkg to nuget.org's symbol server (no --no-symbols). - - name: Push to NuGet.org step - if: ${{ inputs.publish && !inputs.smoke }} - run: | - set -euo pipefail - dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ - --source https://api.nuget.org/v3/index.json \ - --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \ - --skip-duplicate - - # Bundle the build output and packages into a flat Utilities.7z (cd in so the paths are relative). - - name: Compress release asset step - if: ${{ inputs.publish && !inputs.smoke }} - run: | - set -euo pipefail - if ! command -v 7z >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y --no-install-recommends p7zip-full - fi - cd ${{ runner.temp }}/publish - 7z a -t7z ${{ runner.temp }}/Utilities.7z ./* - - # Hand the archive to the github-release job. Plain name (single target). Consumed within this run, - # so retention-days: 1 is the failure-path backstop. - - name: Upload release asset step - if: ${{ inputs.publish && !inputs.smoke }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-asset - path: ${{ runner.temp }}/Utilities.7z - retention-days: 1 - - github-release: - name: Publish GitHub release job - if: ${{ inputs.publish && !inputs.smoke }} - needs: [get-version, validate-release, build] - runs-on: ubuntu-latest - - steps: - - # Same versioned commit (GitCommitId) the build used, so the release tag and files match it. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - - name: Download release asset step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-asset - path: ${{ runner.temp }}/publish - - # A re-run may target an already-released tag. Skip release creation when the tag exists, but let a - # manual dispatch through to refresh it. - - name: Check for existing release step - id: release-exists - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # target_commitish must be the built commit, or GitHub tags the release on the default branch. - - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - generate_release_notes: true - tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ github.ref_name != 'main' }} - fail_on_unmatched_files: true - files: | - LICENSE - README.md - ${{ runner.temp }}/publish/Utilities.7z - - # Reclaim the transfer artifact now the release holds durable copies. Same gate as the create step - # (exists == false || dispatch), so a no-op re-run keeps its artifact for the retention backstop. - # Best-effort: a delete hiccup never fails a successful publish. Needs actions: write from the caller. - - name: Delete consumed release asset step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ - --jq '.artifacts[] | select(.name == "release-asset") | .id'); then - echo "::warning::Could not list run artifacts; retention-days backstop will reap them." - ids="" - fi - for id in $ids; do - gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ - || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." - done +name: Build project release task + +# The single build/version/release task, called by publish-release (real publish) and test-pull-request +# (smoke). NBGV runs once in get-version and threads the version to every consumer. github.ref_name +# decides everything: main builds Release and a stable version, every other branch Debug and a prerelease. +# On a real publish the build job pushes to NuGet via OIDC trusted publishing (no API key) and hands the +# package to the github-release job. Permissions are granted by the caller (this task declares none) so +# the smoke caller, which grants only read, does not startup-fail on a write-scoped job. + +env: + PROJECT_FILE: ./Utilities/Utilities.csproj + +on: + workflow_call: + inputs: + # Create the GitHub release and push to NuGet. Off for smoke. + publish: + required: false + type: boolean + default: false + # Build and test only, never publish or upload. Forces every publish step off regardless of publish. + smoke: + required: false + type: boolean + default: false + secrets: + # nuget.org profile name for OIDC trusted publishing. Not needed for smoke. + NUGET_USERNAME: + required: false + +jobs: + + get-version: + name: Get version information job + runs-on: ubuntu-latest + outputs: + SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} + AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} + AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} + AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + # Default checkout: the triggering commit on its real branch. NBGV classifies from GITHUB_REF + # directly. fetch-depth: 0 gives NBGV the full history for the version height. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot + # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). + - name: Run Nerdbank.GitVersioning tool step + id: nbgv + uses: dotnet/nbgv@master + + # Entry gate: assert branch and version classification agree before any build, so a misclassification + # fails fast. main must be a public release (no prerelease '-'), and every other branch must carry one. + validate-release: + name: Validate release version job + needs: [get-version] + runs-on: ubuntu-latest + steps: + - name: Validate branch and version consistency step + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + REF_NAME: ${{ github.ref_name }} + SMOKE: ${{ inputs.smoke }} + run: | + set -euo pipefail + # Smoke never publishes, so the publish-time branch/version gate does not apply. Smoke also runs + # on branches whose classification will not match an eventual publish target (a feature-branch + # smoke reads prerelease and would trip the main arm of a main-base PR), so skip it on smoke. + if [[ "$SMOKE" == "true" ]]; then + echo "Smoke build; skipping release version validation." + exit 0 + fi + # Strip +buildmetadata. Only a '-' in the core/prerelease segment marks a prerelease. + CORE_AND_PRE="${SEMVER2%%+*}" + if [[ "$REF_NAME" == "main" ]]; then + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + elif [[ "$CORE_AND_PRE" != *-* ]]; then + echo "::error::Prerelease ($REF_NAME) version '$SEMVER2' has no prerelease suffix; refusing to publish." + exit 1 + fi + + build: + name: Build NuGet library job + needs: [get-version, validate-release] + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + # Pin to the versioned commit so the package and the release tag match even if the branch advances + # mid-run. This is a compile checkout only, NBGV already ran in get-version. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} + + # Build the library and its packages into publish/ (both output paths) so they archive together. + - name: Build library step + run: | + set -euo pipefail + dotnet build ${{ env.PROJECT_FILE }} \ + --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + -property:OutputPath=${{ runner.temp }}/publish/ \ + -property:PackageOutputPath=${{ runner.temp }}/publish/ \ + -property:Version=${{ needs.get-version.outputs.SemVer2 }} \ + -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ + -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \ + -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ + -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} + + # OIDC trusted publishing: trade the GitHub OIDC token for a short-lived (1-hour) NuGet key, so there + # is no stored API key. Needs id-token: write from the caller. + - name: NuGet login step + if: ${{ inputs.publish && !inputs.smoke }} + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USERNAME }} + + # Pushing the .nupkg also pushes the co-located .snupkg to nuget.org's symbol server (no --no-symbols). + - name: Push to NuGet.org step + if: ${{ inputs.publish && !inputs.smoke }} + run: | + set -euo pipefail + dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ + --source https://api.nuget.org/v3/index.json \ + --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \ + --skip-duplicate + + # Bundle the build output and packages into a flat Utilities.7z (cd in so the paths are relative). + - name: Compress release asset step + if: ${{ inputs.publish && !inputs.smoke }} + run: | + set -euo pipefail + if ! command -v 7z >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends p7zip-full + fi + cd ${{ runner.temp }}/publish + 7z a -t7z ${{ runner.temp }}/Utilities.7z ./* + + # Hand the archive to the github-release job. Plain name (single target). Consumed within this run, + # so retention-days: 1 is the failure-path backstop. + - name: Upload release asset step + if: ${{ inputs.publish && !inputs.smoke }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset + path: ${{ runner.temp }}/Utilities.7z + retention-days: 1 + + github-release: + name: Publish GitHub release job + if: ${{ inputs.publish && !inputs.smoke }} + needs: [get-version, validate-release, build] + runs-on: ubuntu-latest + + steps: + + # Same versioned commit (GitCommitId) the build used, so the release tag and files match it. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} + + - name: Download release asset step + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-asset + path: ${{ runner.temp }}/publish + + # A re-run may target an already-released tag. Skip release creation when the tag exists, but let a + # manual dispatch through to refresh it. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # target_commitish must be the built commit, or GitHub tags the release on the default branch. + - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + generate_release_notes: true + tag_name: ${{ needs.get-version.outputs.SemVer2 }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} + prerelease: ${{ github.ref_name != 'main' }} + fail_on_unmatched_files: true + files: | + LICENSE + README.md + ${{ runner.temp }}/publish/Utilities.7z + + # Reclaim the transfer artifact now the release holds durable copies. Same gate as the create step + # (exists == false || dispatch), so a no-op re-run keeps its artifact for the retention backstop. + # Best-effort: a delete hiccup never fails a successful publish. Needs actions: write from the caller. + - name: Delete consumed release asset step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ + --jq '.artifacts[] | select(.name == "release-asset") | .id'); then + echo "::warning::Could not list run artifacts; retention-days backstop will reap them." + ids="" + fi + for id in $ids; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ + || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." + done diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index ef25086..7afa3d1 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,87 +1,87 @@ -name: Merge bot pull request action - -# Enables auto-merge on Dependabot pull requests and disables it when a maintainer pushes to the bot -# branch. Auto-merge completes only once the required checks pass, so a breaking update blocks itself. -# Merge method follows the base: squash for develop, merge commit for main. -# -# Every job mints an App token. The built-in GITHUB_TOKEN will not do: its merge commits do not trigger -# downstream workflows (GitHub's recursion guard), and on a Dependabot pull request it is read-only. -# -# Trigger is pull_request_target, not pull_request, so action SHAs resolve from the base branch while the -# jobs hold the App key. Safe because this workflow never checks out PR code, it only runs `gh pr merge` -# against the PR URL. -on: - pull_request_target: - types: [opened, reopened, synchronize] - -# Key on the PR number, not github.ref (the base branch under pull_request_target, which would serialize -# every bot PR). cancel-in-progress: false so a synchronize does not cancel an in-flight opened run before -# it enables auto-merge. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: false - -jobs: - - merge-dependabot: - name: Merge dependabot pull request job - runs-on: ubuntu-latest - # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier - # auto-merges, semver-major included: the required checks are the gate, not the version bump. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Merge pull request step - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;; - esac - gh pr merge --auto --delete-branch "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - disable-auto-merge-on-maintainer-push: - name: Disable auto-merge on maintainer push job - runs-on: ubuntu-latest - # A maintainer push to a bot branch (synchronize, actor is not the bot) disables auto-merge so the - # maintainer's commits do not merge with the bot's. Re-enable manually. The disable call is idempotent. - if: >- - github.event.action == 'synchronize' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.actor != github.event.pull_request.user.login - permissions: - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Disable auto-merge step - run: gh pr merge --disable-auto "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} +name: Merge bot pull request action + +# Enables auto-merge on Dependabot pull requests and disables it when a maintainer pushes to the bot +# branch. Auto-merge completes only once the required checks pass, so a breaking update blocks itself. +# Merge method follows the base: squash for develop, merge commit for main. +# +# Every job mints an App token. The built-in GITHUB_TOKEN will not do: its merge commits do not trigger +# downstream workflows (GitHub's recursion guard), and on a Dependabot pull request it is read-only. +# +# Trigger is pull_request_target, not pull_request, so action SHAs resolve from the base branch while the +# jobs hold the App key. Safe because this workflow never checks out PR code, it only runs `gh pr merge` +# against the PR URL. +on: + pull_request_target: + types: [opened, reopened, synchronize] + +# Key on the PR number, not github.ref (the base branch under pull_request_target, which would serialize +# every bot PR). cancel-in-progress: false so a synchronize does not cancel an in-flight opened run before +# it enables auto-merge. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier + # auto-merges, semver-major included: the required checks are the gate, not the version bump. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;; + esac + gh pr merge --auto --delete-branch "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # A maintainer push to a bot branch (synchronize, actor is not the bot) disables auto-merge so the + # maintainer's commits do not merge with the bot's. Re-enable manually. The disable call is idempotent. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3480ee8..8c40eae 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,56 +1,58 @@ -name: Publish project release action - -# Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. -# -# - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, -# version floor, build configuration, or package versions), or a workflow_dispatch on the target branch. -# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package -# versions (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version -# can't be re-pushed), so a dependency bump must republish to keep the package's declared dependencies -# current - this closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do -# not ship in the package), so an Actions Dependabot bump does not republish (a package-version bump does). -# - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. -# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail -# validation, on any path (push, dispatch, or force-push). -# - The merge-bot merges with an App token, so its merge commits reach this push trigger. -on: - push: - branches: [main, develop] - paths: - - 'Utilities/**' - - 'version.json' - - 'Directory.Build.props' - - 'Directory.Packages.props' - workflow_dispatch: - -# Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is -# never cancelled mid-release (a half-created GitHub release or partial NuGet push). -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -jobs: - - # The same unit-test + lint gate the PR runs. The publish job needs it, so a failing test or lint blocks - # the release. - validate: - name: Validate job - uses: ./.github/workflows/validate-task.yml - - # Build, version, validate, push, and release the triggering branch. Grants the write scopes - # build-release-task needs (it declares none, so the read-only smoke caller is not forced to over-grant). - # The push trigger is already limited to main/develop, and the if-guard covers a workflow_dispatch, which - # can target any branch. - publish: - name: Publish project release job - needs: validate - if: ${{ github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop' }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - permissions: - contents: write - id-token: write - actions: write - with: - publish: true - smoke: false +name: Publish project release action + +# Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. +# +# - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, +# version floor, build configuration, or package versions), or a workflow_dispatch on the target branch. +# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package +# versions (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version +# can't be re-pushed), so a dependency bump must republish to keep the package's declared dependencies +# current - this closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do +# not ship in the package), so an Actions Dependabot bump does not republish (a package-version bump does). +# - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. +# - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail +# validation, on any path (push, dispatch, or force-push). +# - The merge-bot merges with an App token, so its merge commits reach this push trigger. +on: + push: + branches: [main, develop] + paths: + - 'Utilities/**' + - 'version.json' + - 'Directory.Build.props' + - 'Directory.Packages.props' + workflow_dispatch: + +# Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is +# never cancelled mid-release (a half-created GitHub release or partial NuGet push). +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + + # The same unit-test + lint gate the PR runs. The publish job needs it, so a failing test or lint blocks + # the release. + validate: + name: Validate job + uses: ./.github/workflows/validate-task.yml + # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. + secrets: inherit + + # Build, version, validate, push, and release the triggering branch. Grants the write scopes + # build-release-task needs (it declares none, so the read-only smoke caller is not forced to over-grant). + # The push trigger is already limited to main/develop, and the if-guard covers a workflow_dispatch, which + # can target any branch. + publish: + name: Publish project release job + needs: validate + if: ${{ github.event_name == 'push' || github.ref_name == 'main' || github.ref_name == 'develop' }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + id-token: write + actions: write + with: + publish: true + smoke: false diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index bb1106b..df5e561 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,60 +1,62 @@ -name: Test pull request action - -# CI for every branch. Runs on push so the reusable tasks resolve from the pushed head: a PR that edits a -# workflow or the build task tests its own copy. validate-task (unit tests + lint) and smoke-build run on -# every push with no paths filter, so a reusable-workflow change is always exercised. The aggregator below -# is the ruleset required-status-check, produced here on the head SHA. validate-task is the same gate the -# publisher runs, so nothing merges or publishes that would fail it. -# -# There is deliberately no pull_request trigger: a fork PR cannot push to this repo, so it produces no run -# and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch (which -# does push, and so validates) before merging. This is the documented exception (see WORKFLOW.md). -on: - # All branches, but not tags: release tags must not re-run CI. - push: - branches: ['**'] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails). - validate: - name: Validate job - if: ${{ !github.event.deleted }} - uses: ./.github/workflows/validate-task.yml - - # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on - # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it. - smoke-build: - name: Smoke build job - if: ${{ !github.event.deleted }} - uses: ./.github/workflows/build-release-task.yml - permissions: - contents: read - with: - smoke: true - publish: false - - # Single required status check. Its name is the ruleset-bound context in repo-config/ruleset-*.json. Do - # not rename it without updating those in lockstep. Must succeed, not merely not-fail. - check-workflow-status: - name: Check pull request workflow status job - runs-on: ubuntu-latest - needs: [validate, smoke-build] - if: ${{ always() && !github.event.deleted }} - steps: - - name: Check workflow results step - run: | - set -euo pipefail - for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do - name="${result%%:*}" - value="${result#*:}" - if [[ "$value" != "success" ]]; then - echo "::error::Job '$name' did not succeed ($value)." - exit 1 - fi - done +name: Test pull request action + +# CI for every branch. Runs on push so the reusable tasks resolve from the pushed head: a PR that edits a +# workflow or the build task tests its own copy. validate-task (unit tests + lint) and smoke-build run on +# every push with no paths filter, so a reusable-workflow change is always exercised. The aggregator below +# is the ruleset required-status-check, produced here on the head SHA. validate-task is the same gate the +# publisher runs, so nothing merges or publishes that would fail it. +# +# There is deliberately no pull_request trigger: a fork PR cannot push to this repo, so it produces no run +# and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch (which +# does push, and so validates) before merging. This is the documented exception (see WORKFLOW.md). +on: + # All branches, but not tags: release tags must not re-run CI. + push: + branches: ['**'] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails). + validate: + name: Validate job + if: ${{ !github.event.deleted }} + uses: ./.github/workflows/validate-task.yml + # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. + secrets: inherit + + # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on + # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it. + smoke-build: + name: Smoke build job + if: ${{ !github.event.deleted }} + uses: ./.github/workflows/build-release-task.yml + permissions: + contents: read + with: + smoke: true + publish: false + + # Single required status check. Its name is the ruleset-bound context in repo-config/ruleset-*.json. Do + # not rename it without updating those in lockstep. Must succeed, not merely not-fail. + check-workflow-status: + name: Check pull request workflow status job + runs-on: ubuntu-latest + needs: [validate, smoke-build] + if: ${{ always() && !github.event.deleted }} + steps: + - name: Check workflow results step + run: | + set -euo pipefail + for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do + name="${result%%:*}" + value="${result#*:}" + if [[ "$value" != "success" ]]; then + echo "::error::Job '$name' did not succeed ($value)." + exit 1 + fi + done diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index a82b337..151cbfb 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -1,72 +1,85 @@ -name: Validate task - -# The single validation gate: unit tests plus the full lint set (the editor's checks enforced in CI from -# the same config files). Reused by test-pull-request (which produces the required status check) and by -# publish-release (whose publish job needs it), so the PR gate and the publish gate run the identical -# validation from one definition, and nothing publishes that would fail the PR. -on: - workflow_call: - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here. - - name: Run unit tests step - run: dotnet test - - # The same checks the editor runs interactively, enforced in CI from the same config files: CSharpier - # formatting, dotnet format style (EditorConfig), markdownlint, cspell on the user-facing docs, and - # actionlint (which shellchecks every run: block). - lint: - name: Lint job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Check C# formatting step - run: | - set -euo pipefail - dotnet tool restore - dotnet csharpier check . - - - name: Check C# style step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - - - name: Lint markdown step +name: Validate task + +# The single validation gate: unit tests plus the full lint set (the editor's checks enforced in CI from +# the same config files). Reused by test-pull-request (which produces the required status check) and by +# publish-release (whose publish job needs it), so the PR gate and the publish gate run the identical +# validation from one definition, and nothing publishes that would fail the PR. +on: + workflow_call: + +jobs: + + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here. + # --collect drives coverlet.collector to emit Cobertura XML into ./coverage//. + - name: Run unit tests step + run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage + + # Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate. + - name: Upload coverage to Codecov step + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + directory: ./coverage + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + # The same checks the editor runs interactively, enforced in CI from the same config files: CSharpier + # formatting, dotnet format style (EditorConfig), markdownlint, cspell on the user-facing docs, and + # actionlint (which shellchecks every run: block). + lint: + name: Lint job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Check C# formatting step + run: | + set -euo pipefail + dotnet tool restore + dotnet csharpier check . + + - name: Check C# style step + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + + - name: Lint markdown step uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 - with: - globs: '**/*.md' - - # Spell check the user-facing docs. The word list and exclusions live in cspell.json, shared with the - # editor's Code Spell Checker. - - name: Spell check step - uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 - with: - files: | - README.md - HISTORY.md - incremental_files_only: false - - - name: Lint workflows step - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + globs: '**/*.md' + + # Spell check the user-facing docs. The word list and exclusions live in cspell.json, shared with the + # editor's Code Spell Checker. + - name: Spell check step + uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 + with: + files: | + README.md + HISTORY.md + incremental_files_only: false + + - name: Lint workflows step + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + + - name: Check line endings step + run: docker run --rm -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker@sha256:67b9e9b16a674e36f7c05919da789f03a01d343ca8423eb8797179399af07c00 # editorconfig-checker v3.4.0 diff --git a/AGENTS.md b/AGENTS.md index 4e30837..66feba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Applies to code and workflow (`#`) comments alike. ### Line Endings -- [`.editorconfig`](./.editorconfig) defines the correct ending per file type (CRLF for `.md`, `.cs`, XML/`.csproj`/`.props`, `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh`), and [`.gitattributes`](./.gitattributes) (`* -text`) stops git from normalizing. +- [`.editorconfig`](./.editorconfig) defines the correct ending per file type (CRLF for `.md`, `.cs`, XML/`.csproj`/`.props`, non-workflow `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh`, and workflow YAML - `.github/workflows/*.{yml,yaml}` is pinned LF in `.editorconfig` and CI-enforced by editorconfig-checker, not a `.gitattributes` pin), and [`.gitattributes`](./.gitattributes) (`* -text`) stops git from normalizing. - **Editing an existing file: preserve its current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. After any programmatic edit, verify with `git diff --stat` (only changed lines) and `file ` (expected ending). Bring a non-compliant file to its `.editorconfig` ending only as a deliberate, isolated EOL-only change. ### Quantitative Claims diff --git a/Directory.Packages.props b/Directory.Packages.props index 9e02457..860514d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,5 +1,6 @@ + diff --git a/UtilitiesTests/UtilitiesTests.csproj b/UtilitiesTests/UtilitiesTests.csproj index 034db12..bdf2773 100644 --- a/UtilitiesTests/UtilitiesTests.csproj +++ b/UtilitiesTests/UtilitiesTests.csproj @@ -28,6 +28,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +